Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-09-01 13:12:08 +02:00
1 parent 7020ad99fc
commit 9c3e2af209
5 files changed
+731 -154

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
# Changelog – ÖPNV Navi
Alle Änderungen aus den letzten Entwicklungs-Runden, chronologisch. Format
angelehnt an [Keep a Changelog](https://keepachangelog.com/de/).
## Fahrtalarm, Störungs-Push & Live-Karte (Grundlagen)
### Fixed
- **Live-Karte**: Fahrzeuginfo im Isolations-Modus aktualisierte sich nach
dem ersten Klick nie wieder (Marker wurde bei jedem Poll komplett neu
erzeugt statt aktualisiert, wodurch ein offenes Popup sofort wieder
schloss). Gleicher Fix auch für den Trip-Tracking-Modus.
### Added
- **Störungs-Push für Favoriten**: Favoriten-Karten zeigen jetzt Störungen an,
pollen im Hintergrund alle 30s, Toast + optionale Browser-Notification bei
neuen Störungen.
- **Fahrtalarm**: weckt X Stationen vor einer gewählten Zielhaltestelle
(Ton, Vibration, Vollbild-Overlay, persistenter Hinweis-Banner). Setzbar
direkt im Fahrt-Detail-Modal an jeder kommenden Haltestelle.
## Layout & Übersichtlichkeit
### Changed
- **Live-Karte**: nutzt jetzt auf jeder Displaygröße die volle Breite
("Full-Bleed"-Technik, mobile-first, ohne breakpoint-spezifische Werte).
- **Favoriten**: aus dem Abfahrten-Home-Screen entfernt (Desktop wie Mobile
wurden unübersichtlich), jetzt eigenes Tab-Panel – erreichbar über "Mehr"
und einen Icon-Button im Header.
### Added
- **Störungen unterhalb der Abfahrten**: `/departures` und `/db/departures`
liefern jetzt zusätzlich `disruptions[]`, im Frontend direkt unter der
Abfahrtstafel sichtbar.
### Fixed
- Störungstexte mit eingebetteten Links (z. B. `[MEHR/MORE]` von VBB) wurden
komplett als Text escaped statt den Link klickbar zu lassen. Jetzt werden
gezielt nur `<a href="http(s)://...">`-Muster als sichere Links erlaubt,
alles andere bleibt escaped.
## Verlauf, Gleiswechsel, Umstiegszeit, Barrierefreiheit, Teilen, Sprache, Statistik
### Added
- **Verlauf**: zuletzt gesuchte Haltestellen erscheinen jetzt direkt als
Vorschläge beim Antippen des leeren Suchfelds; einzelne Einträge einzeln
entfernbar.
- **Gleiswechsel-Hinweis**: Abfahrtstafel hebt geänderte Gleise deutlich
hervor ("GLEIS 3 statt 5") statt den neuen Wert kommentarlos anzuzeigen.
- **Umstiegszeit-Puffer**: konfigurierbare Mindest-Umstiegszeit für die
OTP2-Routenplanung (Standard/0/3/5/10/15 Min).
- **Barrierefreiheit**: Toggle "Nur rollstuhlgerechte Verbindungen" im
Routenplaner (OTP2-Wheelchair-Preference); Aufzugsstatus wird getrennt von
echten Störungen angezeigt (eigener, ruhigerer Ton).
- **Route teilen**: Link zu einer Suche (nicht einer eingefrorenen
Verbindung) über die Web-Share-API oder Zwischenablage.
- **Mehrsprachigkeit**: Deutsch/Englisch, automatische Spracherkennung,
Umschalter unter "Mehr".
- **Verspätungsstatistik pro Linie**: passiv gesammelt aus ganz normalen
Abfahrten-Abfragen (keine zusätzliche Last auf HAFAS/IRIS), sichtbar im
Fahrt-Detail-Modal ab 10 gesammelten Datenpunkten.
### Fixed
- Gleisänderungs-Störungsmeldungen von HAFAS waren teils inhaltsleer
("Gleisänderung" ohne weitere Angaben). Werden jetzt durch eine aus den
echten Ist-Daten gebaute, präzise Meldung ersetzt (Linie, Ziel, Uhrzeit,
altes/neues Gleis).
- Mobile Bottom-Navigation wurde von der (jetzt display-breiten) Live-Karte
überdeckt (z-index-Konflikt mit Leaflets eigenen Steuerelementen) – behoben.
- Fahrtalarm-Banner konnte sich mit dem Header überlappen (unterschiedliche
Header-Höhen auf Mobile/Desktop) – Position wird jetzt zur Laufzeit anhand
der tatsächlichen Header-Höhe berechnet, nicht mehr geraten.
## Push-Benachrichtigungen & Hintergrundaktualisierung
### Added
- **Web-Push**: funktioniert jetzt auch bei geschlossenem Tab/Browser.
Neuer Service Worker, VAPID-basierte Subscriptions, serverseitige
Hintergrund-Jobs (Störungs-Check alle 60s, Fahrtalarm-Check alle 20s).
Favoriten bleiben primär im `localStorage`; bei aktiviertem Push wird eine
Kopie ans Backend gespiegelt, damit der Server weiß, wonach er schauen soll.
- Neue Backend-Tabellen `push_subscriptions`, `push_favorite_stations`,
`push_trip_alarms`; neue Route `/webpush/*`.
## Barrierefreiheit (gründlicher Durchgang)
### Fixed
- **Suchergebnisse waren nur mit der Maus bedienbar** – größter gefundener
Bug: Tastatur-/Screenreader-Nutzer konnten keine Haltestelle auswählen.
Jetzt per Pfeiltasten/Enter/Escape bedienbar (Hauptsuche + beide
Routenplaner-Felder).
- Abfahrtstafel-Flip-Animation zeigte Screenreadern Zeichensalat während des
Umklappens – jetzt unsichtbare, sofort korrekt lesbare Textalternative.
- Kaputte `role="table"` (ohne Zeilen-/Zellen-Rollen) durch korrektes
`role="list"` ersetzt.
- `aria-pressed` wurde bei "Jetzt/Abfahrt um/Ankunft um" und dem
Sprachumschalter nie aktualisiert (nur die Optik änderte sich) – behoben.
- Fahrtalarm-Banner war keine Live-Region – Änderungen wurden nicht
automatisch vorgelesen.
- Glocken-Buttons im Fahrt-Detail-Modal hatten nur ein `title`-Attribut als
Label (funktioniert auf Touch nicht, uneinheitlich bei Screenreadern) –
jetzt echtes `aria-label`.
### Added
- Skip-Link ("Zum Inhalt springen").
- Echte ARIA-Tab-Semantik (`tablist`/`tab`/`tabpanel`, `aria-selected`,
Pfeiltasten-Navigation) für Desktop- und Mobile-Navigation.
- Fokus-Falle + Fokus-Rückgabe für Fahrt-Detail-Modal und Fahrtalarm-Overlay.
- Formular-Labels für Start/Ziel-Felder im Routenplaner.
- `aria-live`-Regionen für Störungen, Aufzugsstatus, Routenergebnisse.
- Dekorative Emojis systematisch mit `aria-hidden` versehen.
- Farbkontrast geprüft (gedämpfter Text: 5.8–6.6:1, deutlich über WCAG-AA-Minimum).
## Allgemeine Verbesserungen
### Fixed
- "In der Nähe"-Vorschau blieb nach Auswahl einer Haltestelle über der
Abfahrtstafel stehen.
- Beim Wechsel zwischen Haltestellen waren kurz noch die Abfahrten der
vorherigen Station sichtbar (Stale-Data-Flash) – jetzt sofortiger
Ladezustand mit Spinner.
- Wettlauf-Bedingung bei schnellem Tippen in der Suche: eine ältere, aber
langsamer beantwortete Anfrage konnte neuere Ergebnisse überschreiben.
### Added
- "Erneut versuchen"-Buttons bei Netzwerkfehlern (Abfahrtstafel, Routenplaner).
- Browser-Tab-Titel zeigt die gewählte Haltestelle.
- Toast-Bestätigung beim Favorisieren/Entfavorisieren; neue "Erfolg"-Toast-Variante.
- "Störungsbenachrichtigungen aktivieren" ist jetzt eine eigenständige,
farblich zustandsabhängige Karte statt eines unauffälligen Text-Links.
## Visuelles Redesign
### Changed
- Zentrale Design-Tokens für Schatten (`--shadow-sm/md/lg`), Radien und
Glow-Effekte eingeführt statt verstreuter Einzelwerte.
- Hintergrund: mehrschichtige Glow-Gradients, feines Scanline-Muster,
dezente Film-Korn-Textur (SVG-Turbulenz) für einen analogeren statt
sterilen Look.
- Header: Blur/Transparenz-Effekt (Frosted Glass) statt Vollton.
- Abfahrtstafel-Zeilen: farbiger Rand links in der jeweiligen Linienfarbe,
Verlaufs-Hintergrund, Tiefen-Schatten; Split-Flap-Zeichen haben jetzt eine
sichtbare Naht in der Mitte (wie eine echte mechanische Klapp-Anzeige).
- Linienbadges strahlen in ihrer eigenen Farbe (Neon-Effekt), Uhrzeit mit
grünem LED-Glühen.
- Logo und aktiver Tab pulsieren dezent in Amber; LIVE-Statuspunkt mit
echtem Leucht-Ring-Puls.
- Karten (Favoriten, "Mehr"-Kacheln, Aktionen) heben sich beim Hover an
(Schatten + Lift statt nur Farbwechsel), gestaffelte Eintritts-Animation
beim (Neu-)Aufbau von Listen.
- Button-Übergänge mit leichter Feder/Überschwung-Kurve statt linear.
- Alle neuen Animationen respektieren `prefers-reduced-motion` (bereits
global vorhandene Regel greift automatisch).
---
*Nicht umgesetzt / bewusst zurückgestellt:* GTFS-RT-Live-Anbindung für
Fahrzeugpositionen/Störungsmeldungen sowie GTFS-`routes.txt` als
autoritative Quelle für Linienfarben (aktuell: `vbb-line-colors`-Paket +
Fallback-Heuristik nach Verkehrsmittel).
## Repo-Aufräumung (Server-seitig)
Auf dem Produktivserver hatten sich zwei parallele Code-Bäume angesammelt:
eine alte, seit Ende August unangetastete Root-Ebene (`routes/`, `utils/`,
`db/`, `middleware/`, `server.js` direkt im Projektordner) und der tatsächlich
laufende, aktuelle Code unter `src/` (per `package.json` als Einstiegspunkt
konfiguriert). Zusätzlich gab es doppelte `fares.js`-Dateien und eine
verwaiste Editor-Backup-Datei (`disruptionFilter.js+`).
### Changed
- Aktueller Code aus `src/` eine Ebene hoch ins Projekt-Root verschoben,
`src/` entfernt – keine verschachtelte Struktur mehr.
- Datenordner von `data/` zu `db-data/` umbenannt (eindeutiger Name, da
`db/` bereits für Code – `database.js` – reserviert ist), `DB_PATH` in
`.env` entsprechend angepasst.
- `package.json` (`main`/`start`/`dev`) und der echte systemd-Service
(`ExecStart`) zeigen jetzt auf `server.js` statt `src/server.js`.
- `.env.example` und `oepnv-navi-backend.service.example` aktualisiert
(fehlende `OTP_BASE_URL`/`VAPID_*`-Platzhalter ergänzt, `DB_PATH`
korrigiert, `ExecStart`-Pfad korrigiert).
- **Auslieferungsstruktur ab jetzt**: neue Code-Übergaben verwenden dieselbe
flache Struktur (kein `src/`-Präfix mehr), um erneute Divergenz zwischen
gelieferten und tatsächlich laufenden Dateien zu vermeiden.
### Removed
- Toter Root-Ebene-Code (`routes/`, `utils/`, `db/`, `middleware/`,
`server.js` von vor der gemeinsamen Entwicklung), doppelte `fares.js` im
alten `data/`-Ordner, verwaister `src/data/data/`-Unterordner,
`.env.old`, `disruptionFilter.js+` (veraltete Editor-Backup-Datei ohne die
Gleisänderungs-Anreicherung).
## Backend-Härtung & letzte Politur
### Added
- **Sauberes Herunterfahren** (`SIGTERM`/`SIGINT`): schließt den HTTP-Server
und die SQLite-Verbindung geordnet, statt bei jedem Neustart/Deploy hart
abgewürgt zu werden (relevant v. a. bei systemd-Neustarts). Erzwingt nach
10s Timeout einen harten Abbruch, falls hängende Requests das saubere
Beenden blockieren.
- Größenlimit für JSON-Request-Bodies (200kb) als einfacher Missbrauchsschutz.
### Changed
- Verkehrsmittel-Filter-Chips (S/U/Tram/Bus/…) haben jetzt ausgeschriebene
`aria-label` ("S-Bahn", "Regionalverkehr (RE/RB)" usw.) statt nur der
kryptischen Kurz-Beschriftung für Screenreader-Nutzer.
## Visuelles Feintuning nach Nutzer-Feedback
### Changed
- Die Redesign-Effekte aus der "übertreiben"-Runde (Scanlines, Film-Korn-
Textur, dauerhaft pulsierendes Logo/LIVE-Punkt, wandernder Lichtschein auf
der Willkommens-Karte, Neon-Überglühen der Linienbadges, LED-Leuchten der
Uhrzeit, federnde Button-Übergänge) auf Wunsch wieder zurückgenommen -
zurück zu einem ruhigeren, professionelleren Look. Struktur-Substanz
(Schatten-Skala, Glass-Header, Karten-Hover, farbiger Linien-Rand pro
Abfahrtszeile) blieb erhalten.
- Naht-Linie in der Mitte jedes Split-Flap-Zeichens (rein kosmetisches Detail
aus derselben Runde) auf Wunsch wieder entfernt.
- Toast-Benachrichtigungen überarbeitet: Icon-Kreis pro Typ (✓/⚠/ℹ),
Schließen-Button, dezente Fortschrittsleiste synchron zur Anzeigedauer.
- Dieselbe Icon-Kreis-Formsprache auf Störungen, Aufzugsstatus und beide
Störungslisten ("In deiner Nähe", netzweit) übertragen - einheitliche
`.notice-item`-Struktur statt vier verschiedener Ad-hoc-Layouts.
### Fixed
- **Lange Zugbezeichnungen** (z.B. "ICE 940") sprengten ihre feste
Spaltenbreite und liefen optisch in die Ziel-Spalte über. Erst testweise
auf variable Spaltenbreite umgestellt, dann auf Nutzerwunsch wieder auf
feste Breite zurückgesetzt (Trennlinien sollen über alle Zeilen fluchten) -
stattdessen läuft der Text jetzt bei Bedarf automatisch wie ein Ticker
durch seine Box (kein manuelles Scrollen nötig), inkl. mehrerer
Nachbesserungen: falsche Lauflänge durch nicht mitgerechnetes Padding
behoben (letztes Zeichen war nie sichtbar), Ausblende-Verlauf entfernt
(verschluckte genau das Textende), Wartezeit am Anfang entfernt, Tempo
spürbar verlangsamt, kein Neustart der Animation mehr bei jedem 30s-Poll.
- Zweite Trennlinie zwischen Ziel und Abfahrtszeit ergänzt (gab es bisher nur
zwischen Linie und Ziel).
## Backend: weitere Störungs-Filter-Verbesserung
### Fixed
- Generische HAFAS-Meldung ("Abfahrt heute von Gleis X") wird jetzt ebenso
herausgefiltert wie die bereits vorher gefilterte generische
"Gleisänderung"-Meldung - beide sind durch die präzise, aus Ist-Daten
gebaute Gleisänderungs-Meldung (Linie/Ziel/Uhrzeit/altes+neues Gleis)
vollständig abgedeckt und wären sonst redundant doppelt angezeigt worden.
## Autonomer Start am aktuellen Standort
### Added
- **App startet jetzt immer am aktuellen Standort** statt an der zuletzt
besuchten Haltestelle: beim Öffnen wird automatisch der Standort abgefragt,
die nächstgelegene Haltestelle ausgewählt und ihre Abfahrten direkt gezeigt
- ohne Klick. Weitere nahe Haltestellen erscheinen zusätzlich als Vorschau.
Schlägt das fehl (Berechtigung verweigert, kein Standort, keine Treffer),
fällt die App sauber auf die zuletzt besuchte Haltestelle zurück.
- **Störungen "In deiner Nähe"** laden jetzt automatisch beim ersten Öffnen
des Störungen-Tabs, statt einen Klick auf "Standort verwenden" zu
erfordern - nutzt dafür den beim Start bereits ermittelten Standort weiter.
Störungsprüfung deckt damit durchgängig sowohl Favoriten (bestehendes
Störungs-Push-Feature) als auch den aktuellen Standort ab.
### Removed
- Tote, nie aufgerufene Funktion `loadNearbyPreview()` - durch die neue,
vollständige Autostart-Logik ersetzt.
+73 -83
View File
@@ -1,97 +1,87 @@
# ÖPNV Navi – Backend
# ÖPNV Navi – Frontend
Backend für die geplante "ÖPNV Navi" iOS-App (Nachfolger der VBB-Status-Web-App).
Stellt VBB-Echtzeitdaten (Abfahrten, Routenplaner, Live-Map) sowie eigene Features
(Favoriten, Push-Token-Registrierung) über eine REST-API bereit.
Vanilla-JS-Webapp für Live-Abfahrten, Routenplanung und eine Live-Karte im
VBB-Verbund (Berlin/Brandenburg) mit Anbindung an echte DB-Bahnhöfe. Kein
Build-Schritt, kein Framework – reines HTML/CSS/JS, läuft direkt als
statische Website.
## 1. Installation
## Tech-Stack
```bash
cd oepnv-navi-backend
npm install
cp .env.example .env
- Reines HTML5, CSS3, ES2020+ JavaScript (keine Frameworks, kein Bundler)
- [Leaflet](https://leafletjs.com/) für die Live-Karte (per CDN eingebunden)
- Google Fonts: IBM Plex Mono / Sans / Sans Condensed
- Service Worker für Web-Push-Benachrichtigungen
## Ordnerstruktur
```
frontend/
├── index.html Einzige HTML-Seite (Single Page App über Tabs)
├── styles.css Komplettes Stylesheet ("Solari-Anzeigetafel bei Nacht"-Theme)
├── sw.js Service Worker (Web-Push) - MUSS im Root liegen, nicht unter js/
├── manifest.json PWA-Manifest
└── js/
├── config.js Zentrale Konfiguration (API_BASE-URL des Backends)
├── api.js Schlanker fetch()-Wrapper für alle Backend-Endpunkte
├── i18n.js Mehrsprachigkeit (DE/EN), Wörterbuch + Sprachumschaltung
├── app.js Hauptlogik: Tabs, Suche, Abfahrtstafel, Favoriten, Routenplaner
├── splitflap.js Flip-Animation der Abfahrtstafel (+ Screenreader-Textalternative)
├── modefilter.js Wiederverwendbare Verkehrsmittel-Filter-Chips
├── livemap.js Live-Karte (Leaflet): Radar-Modus, Trip-Tracking, Isolations-Modus
├── tripmodal.js Fahrt-Detail-Modal (Zwischenhalte, Mini-Karte, Verspätungsstatistik)
├── tripalarm.js Fahrtalarm ("wecke mich X Stationen vorher")
└── push.js Web-Push: Service-Worker-Registrierung, Abo-Verwaltung
```
Öffne `.env` und passe mindestens `API_KEY` an (z.B. mit `openssl rand -hex 32` erzeugen).
## Einrichtung / Deployment
## 2. Lokal starten
Keine Installation nötig – es sind statische Dateien. Einfach den kompletten
Ordnerinhalt auf einen Webserver (nginx, Apache, Vercel, o.ä.) legen.
```bash
npm start
**Wichtig:**
- `sw.js` muss im **Root** des ausgelieferten Verzeichnisses liegen (dort, wo
auch `index.html` liegt), nicht unter `js/`. Der Geltungsbereich eines
Service Workers ist standardmäßig auf sein eigenes Verzeichnis und alles
darunter beschränkt.
- Web Push (und Service Worker generell) funktionieren nur über **HTTPS**
(oder `localhost` während der Entwicklung).
- In `js/config.js` die Backend-URL eintragen:
```js
window.APP_CONFIG = {
API_BASE: 'https://dein-server.example/live_navi',
};
```
Test:
## Features im Überblick
```bash
curl http://localhost:3000/health
curl "http://localhost:3000/live_navi/stations/search?query=Alexanderplatz"
```
- **Abfahrten**: Live-Abfahrtstafel (VBB + echte DB-Bahnhöfe), Verkehrsmittel-Filter,
Gleiswechsel-Hinweis, Störungen & Aufzugsstatus direkt unter der Tafel
- **Route**: Routenplanung über eine selbst gehostete OTP2-Instanz, konfigurierbarer
Umstiegszeit-Puffer, rollstuhlgerechte Verbindungen, Route teilen (Link)
- **Karte**: Live-Fahrzeugpositionen, Fahrt isolieren/verfolgen, display-breite
Darstellung auf jeder Bildschirmgröße
- **Favoriten**: eigener Tab, Live-Vorschau der nächsten Abfahrt, Störungs-Badges
- **Fahrtalarm**: weckt X Stationen vor dem Ziel (Ton, Vibration, Vollbild-Overlay)
- **Push-Benachrichtigungen**: funktionieren auch bei geschlossenem Tab/Browser
(Service Worker + Web Push, siehe `js/push.js`)
- **Verlauf**: zuletzt gesuchte Haltestellen, direkt als Vorschläge im Suchfeld
- **Mehrsprachigkeit**: Deutsch/Englisch, automatische Browsersprache-Erkennung
- **Barrierefreiheit**: Tastaturbedienung überall (inkl. Suche), ARIA-Tab-Pattern,
Fokus-Fallen in Dialogen, Screenreader-Textalternative zur Flip-Animation,
`aria-live`-Regionen für Störungen/Ergebnisse
## 3. Als Dienst laufen lassen (systemd)
## Browser-Unterstützung
1. Projekt auf den Server kopieren, z.B. nach `/home/pi/oepnv-navi-backend`
2. `oepnv-navi-backend.service.example` nach `/etc/systemd/system/oepnv-navi-backend.service`
kopieren und die Platzhalter (`DEIN_USER`, Pfade) anpassen
3. Aktivieren:
Moderne Evergreen-Browser (Chrome, Firefox, Safari, Edge). Nutzt u. a.
`fetch`, `IntersectionObserver`-freies Vanilla-DOM, Service Worker, Push API,
CSS Custom Properties und `dvh`-Einheiten (mit `vh`-Fallback). Kein IE11-Support.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now oepnv-navi-backend
sudo systemctl status oepnv-navi-backend
```
## Bekannte Grenzen
## 4. Einrichtung in Nginx Proxy Manager (NPM)
Da Backend-Server und NPM auf unterschiedlichen Hosts im selben LAN laufen, brauchst du
die **lokale IP** des Backend-Servers (z.B. `192.168.1.50`) und den Port aus `.env` (Standard `3000`).
**Falls `serverlele.ddns.net` schon einen Proxy Host für deine Web-App hat:**
1. In NPM → *Proxy Hosts* → bestehenden Eintrag für `serverlele.ddns.net` öffnen
2. Tab *Custom Locations* → *Add location*
- **Location**: `/live_navi`
- **Scheme**: `http`
- **Forward Hostname / IP**: die LAN-IP deines Backend-Servers, z.B. `192.168.1.50`
- **Forward Port**: `3000`
3. Speichern
**Falls es noch keinen Proxy Host für die Domain gibt:**
1. *Proxy Hosts* → *Add Proxy Host*
2. Domain: `serverlele.ddns.net`
3. Scheme/Forward: kann auf deine bestehende Web-App zeigen (Root `/`)
4. SSL-Tab: Let's Encrypt Zertifikat aktivieren (Force SSL empfehlenswert)
5. Danach wie oben die Custom Location `/live_navi` hinzufügen
Wichtig: Das Backend selbst lauscht (via `BASE_PATH` in `.env`) bereits unter `/live_navi/...`,
weil NPM den vollen Pfad inkl. Präfix an das Backend weiterreicht. Du musst also **keine**
zusätzliche Rewrite-Regel in NPM konfigurieren – einfach die Custom Location wie oben anlegen.
Test von außen, sobald DNS/Portfreigabe für `serverlele.ddns.net` stehen:
```bash
curl https://serverlele.ddns.net/live_navi/health
```
## 5. API-Übersicht
| Methode | Pfad | Auth | Beschreibung |
|---------|------------------------------------|-----------|------------------------------------------------|
| GET | `/live_navi/health` | – | Health-Check |
| GET | `/live_navi/stations/search` | – | Haltestellensuche (`?query=`) |
| GET | `/live_navi/stations/:id` | – | Details einer Haltestelle |
| GET | `/live_navi/departures/:stationId`| – | Live-Abfahrten (`?duration=&results=`) |
| GET | `/live_navi/journeys` | – | Routenplaner (`?from=&to=&when=`) |
| GET | `/live_navi/radar` | – | Live-Fahrzeugpositionen (`?north=&west=&south=&east=`) |
| GET | `/live_navi/favorites` | – | Liste der Favoriten |
| POST | `/live_navi/favorites` | API-Key | Favorit anlegen |
| DELETE | `/live_navi/favorites/:id` | API-Key | Favorit löschen |
| POST | `/live_navi/push/register` | API-Key | Push-Token registrieren |
Für geschützte Endpunkte den Header `X-API-Key: <dein-key-aus-.env>` mitschicken.
## 6. Nächste Schritte
- Rate-Limit-Werte in `src/server.js` an echten Bedarf anpassen
- Später: Push-Notifications (APNs) auf Basis der `push_tokens`-Tabelle bauen
- Später: iOS-App (Swift/SwiftUI) als Client gegen dieses Backend entwickeln
- Die Live-Karte ist für blinde Nutzer naturgemäß eingeschränkt zugänglich
(Leaflet-Karten sind visuell) – Abfahrtstafel und Routenplaner liefern
dieselben Informationen barrierefrei.
- Störungstexte kommen direkt von VBB/HAFAS auf Deutsch – eine Übersetzung
ins Englische findet nicht statt (nur die App-Oberfläche selbst ist zweisprachig).
- Fahrtalarm-Ton und Vollbild-Overlay funktionieren nur bei offenem Tab; bei
geschlossenem Browser zeigt ein Push nur die Systembenachrichtigung.
+155 -31
View File
@@ -299,17 +299,43 @@ const App = (() => {
const container = document.getElementById('toast-container');
if (!container) return;
const ICONS = { success: '✓', warning: '⚠', info: 'ℹ' };
const toast = document.createElement('div');
toast.className = `toast toast--${variant}`;
toast.textContent = message;
toast.innerHTML = `
<span class="toast__icon" aria-hidden="true">${ICONS[variant] || ICONS.info}</span>
<span class="toast__message"></span>
<button class="toast__close" type="button" aria-label="Schließen">×</button>
<span class="toast__progress"></span>
`;
toast.querySelector('.toast__message').textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('is-visible'));
const AUTO_DISMISS_MS = 6000;
let dismissTimer = null;
setTimeout(() => {
function dismiss() {
clearTimeout(dismissTimer);
toast.classList.remove('is-visible');
setTimeout(() => toast.remove(), 300);
}, 6000);
setTimeout(() => toast.remove(), 250);
}
toast.querySelector('.toast__close').addEventListener('click', dismiss);
requestAnimationFrame(() => {
toast.classList.add('is-visible');
// Countdown-Leiste per CSS-Transition auf 0 fahren, synchron zur
// tatsächlichen Anzeigedauer - zeigt beiläufig, wie viel Zeit bleibt,
// ohne dass man aktiv draufschauen muss.
const progress = toast.querySelector('.toast__progress');
if (progress) {
progress.style.transitionDuration = `${AUTO_DISMISS_MS}ms`;
requestAnimationFrame(() => { progress.style.transform = 'scaleX(0)'; });
}
});
dismissTimer = setTimeout(dismiss, AUTO_DISMISS_MS);
}
// ---------- Browser-Benachrichtigungen an/aus ----------
@@ -377,6 +403,8 @@ const App = (() => {
btn.classList.toggle('is-active', active);
}
let lastKnownLocation = null; // { lat, lon } - einmal ermittelt, für Autostart & Störungen wiederverwendet
function getUserLocation() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
@@ -384,7 +412,10 @@ const App = (() => {
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
(pos) => {
lastKnownLocation = { lat: pos.coords.latitude, lon: pos.coords.longitude };
resolve(lastKnownLocation);
},
(err) => reject(new Error('Standort konnte nicht ermittelt werden: ' + err.message)),
{ enableHighAccuracy: true, timeout: 10000 },
);
@@ -658,7 +689,15 @@ const App = (() => {
} else {
elevatorEl.hidden = false;
elevatorEl.innerHTML = elevatorItems
.map((d) => `<div class="elevator-status__item">♿ <strong>${escapeHtml(d.summary || 'Aufzugsstatus')}</strong>${d.text ? `<br>${linkifyRemarkText(d.text)}` : ''}</div>`)
.map((d) => `
<div class="notice-item">
<span class="notice-item__icon" aria-hidden="true">♿</span>
<div class="notice-item__body">
<strong>${escapeHtml(d.summary || 'Aufzugsstatus')}</strong>
${d.text ? `<span class="notice-item__text">${linkifyRemarkText(d.text)}</span>` : ''}
</div>
</div>
`)
.join('');
}
}
@@ -670,7 +709,15 @@ const App = (() => {
} else {
disruptionEl.hidden = false;
disruptionEl.innerHTML = otherItems
.map((d) => `<div class="station-disruption__item">⚠ <strong>${escapeHtml(d.summary || 'Störung')}</strong>${d.text ? `<br>${linkifyRemarkText(d.text)}` : ''}</div>`)
.map((d) => `
<div class="notice-item">
<span class="notice-item__icon" aria-hidden="true">⚠</span>
<div class="notice-item__body">
<strong>${escapeHtml(d.summary || 'Störung')}</strong>
${d.text ? `<span class="notice-item__text">${linkifyRemarkText(d.text)}</span>` : ''}
</div>
</div>
`)
.join('');
}
}
@@ -778,6 +825,52 @@ const App = (() => {
row.style.setProperty('--line-color', dep.color || '#8E99A6');
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
// Bei langen Zugbezeichnungen (z.B. "ICE 940") reicht die feste
// Spaltenbreite nicht - der Text läuft dann von selbst wie ein Ticker
// durch (CSS-Animation), kein manuelles Scrollen nötig. Hier nur messen,
// wie weit der Text übersteht, und Distanz/Tempo als CSS-Variablen setzen.
// requestAnimationFrame, damit die SplitFlap-Zeichen-Spans sicher schon
// im DOM stehen, bevor die Breite gemessen wird.
requestAnimationFrame(() => {
const visual = lineCell.querySelector('.flap-cell__visual');
let overflowPx = 0;
if (visual) {
// Echte Geometrie statt clientWidth/scrollWidth-Schätzung: clientWidth
// schließt das eigene Padding der Badge mit ein, wodurch die zuvor
// berechnete Laufweite immer um genau das rechte Padding zu kurz war -
// das letzte Zeichen war dadurch nie vollständig sichtbar. Hier wird
// stattdessen direkt gemessen, wie weit der rechte Rand des Textes
// über den rechten Innenrand (Content-Box, ohne Padding) der Badge hinausragt.
const lineRect = lineCell.getBoundingClientRect();
const visualRect = visual.getBoundingClientRect();
const paddingRight = parseFloat(getComputedStyle(lineCell).paddingRight) || 0;
const contentBoxRight = lineRect.right - paddingRight;
overflowPx = Math.max(0, visualRect.right - contentBoxRight);
}
const isScrollable = overflowPx > 1;
// Nicht bei jedem 30s-Poll unangetastet neu antriggern, wenn sich an der
// Überstands-Länge nichts geändert hat - sonst startet die Animation
// immer wieder von vorn und wirkt dadurch unruhiger/hektischer, als sie
// eigentlich ist.
const previousDistance = lineCell.dataset.marqueeOverflow;
const currentDistance = String(Math.round(overflowPx));
if (previousDistance === currentDistance) return;
lineCell.dataset.marqueeOverflow = currentDistance;
lineCell.classList.toggle('flap-cell--line--scrollable', isScrollable);
if (isScrollable) {
// +2px statt einer größeren Zugabe - die Distanz soll das letzte
// Zeichen exakt an den Innenrand bringen, nicht noch weiter drüber hinaus.
// Bewusst langsam und ruhig: 12px/s als Grundtempo, mindestens 4.5s pro
// Durchlauf, damit es wie ein gemächlicher Bahnhofstafel-Ticker wirkt
// statt hektisch hin- und herzuschnellen.
lineCell.style.setProperty('--marquee-distance', `-${overflowPx + 2}px`);
lineCell.style.setProperty('--marquee-duration', `${Math.max(4.5, overflowPx / 12).toFixed(1)}s`);
} else {
lineCell.style.removeProperty('--marquee-distance');
lineCell.style.removeProperty('--marquee-duration');
}
});
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
// Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach
@@ -895,6 +988,10 @@ const App = (() => {
if (tabName === 'disruptions' && !remarksLoadedOnce) {
loadRemarks();
// "In deiner Nähe" automatisch mitladen statt auf den manuellen
// "Standort verwenden"-Klick zu warten - Störungen sollen immer sowohl
// für die Favoriten als auch für den aktuellen Standort da sein.
loadNearbyDisruptions();
}
if (tabName === 'fares' && !faresLoadedOnce) {
@@ -935,19 +1032,8 @@ const App = (() => {
document.getElementById('nearby-home').hidden = locations.length === 0;
}
async function loadNearbyPreview() {
const home = document.getElementById('nearby-home');
const list = document.getElementById('nearby-home-list');
if (!navigator.geolocation || !home || !list) return;
list.innerHTML = '<div class="recent-empty">Standort wird ermittelt…</div>';
try {
const { lat, lon } = await getUserLocation();
const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 3 });
renderNearbyPreview(locations);
} catch (_) {
home.hidden = true;
}
}
// (Autostart am Standort - inkl. Vorschau der übrigen nahen Haltestellen -
// übernimmt jetzt autonom autoStartFromLocation() beim App-Start.)
function renderNearbyList(locations, container) {
container.innerHTML = '';
@@ -975,18 +1061,26 @@ const App = (() => {
function initNearbyDisruptions() {
const btn = document.getElementById('disruptions-locate');
if (!btn) return;
btn.addEventListener('click', async () => {
btn.addEventListener('click', () => loadNearbyDisruptions());
}
// Lädt Störungen rund um den aktuellen Standort - wird sowohl vom manuellen
// "Standort verwenden"-Button als auch automatisch beim ersten Öffnen des
// Störungen-Tabs aufgerufen (siehe activateTab()), damit man dafür nicht
// erst extra klicken muss. Nutzt den beim Start bereits ermittelten
// Standort weiter, statt ihn ein zweites Mal abzufragen, falls vorhanden.
async function loadNearbyDisruptions() {
const container = document.getElementById('nearby-disruptions-list');
if (!container) return;
container.innerHTML = '<div class="board-empty">Standort wird ermittelt…</div>';
try {
const { lat, lon } = await getUserLocation();
const { lat, lon } = lastKnownLocation || (await getUserLocation());
container.innerHTML = '<div class="board-empty">Suche Störungen in der Nähe…</div>';
const { disruptions } = await API.getNearbyDisruptions({ lat, lon, distance: 1000, stops: 6 });
renderNearbyDisruptions(disruptions, container);
} catch (err) {
container.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
}
});
}
function renderNearbyDisruptions(disruptions, container) {
@@ -1001,9 +1095,14 @@ const App = (() => {
const lines = d.affectedLines.length ? `Linie${d.affectedLines.length > 1 ? 'n' : ''} ${d.affectedLines.join(', ')}` : '';
const stops = d.affectedStops.length ? ` · nahe ${d.affectedStops.join(', ')}` : '';
card.innerHTML = `
<div class="remark-card__summary">⚠ ${escapeHtml(d.summary || 'Hinweis')}</div>
<div class="remark-card__text">${linkifyRemarkText(d.text || '')}</div>
<div class="remark-card__meta">${escapeHtml(lines)}${escapeHtml(stops)}</div>
<div class="notice-item">
<span class="notice-item__icon" aria-hidden="true">⚠</span>
<div class="notice-item__body">
<strong>${escapeHtml(d.summary || 'Hinweis')}</strong>
<span class="notice-item__text">${linkifyRemarkText(d.text || '')}</span>
<span class="notice-item__meta">${escapeHtml(lines)}${escapeHtml(stops)}</span>
</div>
</div>
`;
container.appendChild(card);
});
@@ -1069,9 +1168,14 @@ const App = (() => {
const card = document.createElement('div');
card.className = 'remark-card';
card.innerHTML = `
<div class="remark-card__summary">⚠ ${escapeHtml(r.summary || 'Hinweis')}</div>
<div class="remark-card__text">${linkifyRemarkText(r.text || '')}</div>
<div class="remark-card__meta">${escapeHtml(r.company || '')}</div>
<div class="notice-item">
<span class="notice-item__icon" aria-hidden="true">⚠</span>
<div class="notice-item__body">
<strong>${escapeHtml(r.summary || 'Hinweis')}</strong>
<span class="notice-item__text">${linkifyRemarkText(r.text || '')}</span>
<span class="notice-item__meta">${escapeHtml(r.company || '')}</span>
</div>
</div>
`;
container.appendChild(card);
});
@@ -1445,6 +1549,26 @@ const App = (() => {
}
}
// Startet die App am aktuellen Standort statt an der zuletzt gewählten
// Haltestelle: sucht Haltestellen in der Nähe, wählt automatisch die
// nächstgelegene aus (Abfahrten erscheinen ohne weiteren Klick) und füllt
// nebenbei die "In der Nähe"-Vorschau mit den übrigen Treffern. Klappt das
// nicht (Berechtigung verweigert, kein Standort, keine Treffer in der Nähe),
// fällt die App auf die zuletzt besuchte Haltestelle zurück statt leer zu bleiben.
async function autoStartFromLocation() {
try {
const { lat, lon } = await getUserLocation();
const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 15 });
if (!locations.length) throw new Error('Keine Haltestellen in der Nähe gefunden.');
selectStation(locations[0]);
renderNearbyPreview(locations.slice(1));
} catch (err) {
console.error('Autostart am Standort nicht möglich, nutze letzte Haltestelle:', err.message);
restoreLastStop();
}
}
function init() {
I18N.init();
if (window.Push && Push.isSupported()) Push.registerServiceWorker();
@@ -1492,7 +1616,7 @@ const App = (() => {
if (currentStop) toggleFavorite(currentStop);
});
restoreLastStop();
autoStartFromLocation();
restoreJourneyFromShareLink();
}
+15
View File
@@ -11,6 +11,18 @@ const ModeFilter = (() => {
express: 'Fern',
regional: 'RE/RB',
};
// Ausgeschriebene Namen für aria-label - die kurzen Chip-Beschriftungen
// ("S", "U", "Fern") sind für Screenreader-Nutzer ohne visuellen Kontext
// zu kryptisch, um zuverlässig zu wissen, welches Verkehrsmittel gemeint ist.
const ARIA_LABELS = {
suburban: 'S-Bahn',
subway: 'U-Bahn',
tram: 'Tram',
bus: 'Bus',
ferry: 'Fähre',
express: 'Fernverkehr',
regional: 'Regionalverkehr (RE/RB)',
};
const ALL = Object.keys(LABELS);
function create(container, storageKey, onChange) {
@@ -38,6 +50,9 @@ const ModeFilter = (() => {
btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : '');
btn.textContent = LABELS[mode];
btn.setAttribute('aria-pressed', selected.has(mode) ? 'true' : 'false');
// aria-pressed übernimmt schon die Zustandsansage ("gedrückt"/"nicht
// gedrückt") - hier nur der ausgeschriebene Name, keine Dopplung.
btn.setAttribute('aria-label', ARIA_LABELS[mode]);
btn.addEventListener('click', () => {
if (selected.has(mode)) {
selected.delete(mode);
+221 -40
View File
@@ -7,10 +7,12 @@
--bg-void: #0C1116;
--panel: #171E26;
--panel-raised: #212A34;
--panel-overlay: #26303C;
--line: #2A323D;
--ink: #EDEFEC;
--ink-muted: #8E99A6;
--signal-amber: #F5A623;
--signal-amber-soft: rgba(245, 166, 35, 0.14);
--signal-green: #3DDC84;
--signal-red: #FF5D6C;
--signal-blue: #4FA8E8;
@@ -20,7 +22,13 @@
--font-label: 'IBM Plex Sans Condensed', sans-serif;
--radius: 10px;
--radius-lg: 18px;
--header-height: 64px;
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.22);
--shadow-md: 0 10px 26px rgba(0, 0, 0, 0.32);
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.5);
--shadow-glow-amber: 0 0 0 1px rgba(245, 166, 35, 0.25), 0 8px 24px rgba(245, 166, 35, 0.12);
}
* {
@@ -31,6 +39,10 @@ html, body {
margin: 0;
padding: 0;
background: var(--bg-void);
background-image:
radial-gradient(ellipse 1000px 500px at 20% -8%, rgba(245, 166, 35, 0.05), transparent 60%),
radial-gradient(ellipse 800px 500px at 100% 0%, rgba(79, 168, 232, 0.04), transparent 55%);
background-attachment: fixed;
color: var(--ink);
font-family: var(--font-body);
min-height: 100vh;
@@ -100,8 +112,11 @@ button {
align-items: center;
gap: 20px;
padding: 0 20px;
background: var(--panel);
background: rgba(23, 30, 38, 0.85);
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
border-bottom: 1px solid var(--line);
box-shadow: var(--shadow-sm), 0 1px 0 rgba(245, 166, 35, 0.15);
position: sticky;
top: 0;
z-index: 20;
@@ -237,8 +252,8 @@ button {
.notify-callout__text small { display: block; color: var(--ink-muted); font-size: 12px; margin-top: 2px; }
.notify-callout__btn {
flex-shrink: 0;
background: var(--signal-amber);
color: #17110a;
background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%);
color: #1a1206;
font-weight: 600;
border: 0;
border-radius: 999px;
@@ -296,6 +311,7 @@ button {
.tab-button.is-active {
color: var(--signal-amber);
border-bottom-color: var(--signal-amber);
box-shadow: 0 2px 12px -2px rgba(245, 166, 35, 0.6);
}
.app-main {
@@ -391,14 +407,22 @@ button {
grid-template-columns: 82px minmax(0, 1fr) 82px 112px;
gap: 18px;
align-items: center;
background: var(--panel);
background: linear-gradient(180deg, var(--panel-overlay), var(--panel) 60%);
border: 1px solid var(--line);
border-left: 4px solid var(--line-color, var(--line));
border-radius: var(--radius);
padding: 14px 16px 14px 13px;
font-family: var(--font-display);
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
animation: flap-row-in .25s ease both;
animation-delay: calc(var(--row-index, 0) * 25ms);
transition: box-shadow .15s ease, transform .15s ease;
}
.flap-row--clickable:hover,
.flap-row--clickable:focus-visible {
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
transform: translateY(-1px);
}
@keyframes flap-row-in {
@@ -454,7 +478,8 @@ button {
}
.flap-cell--line {
width: max-content;
display: block;
max-width: 100%;
min-width: 38px;
font-weight: 700;
color: var(--line-text-color, var(--signal-amber));
@@ -462,6 +487,33 @@ button {
padding: 5px 9px;
border-radius: 4px;
font-size: 16px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15);
/* Lange Zugbezeichnungen (z.B. "ICE 940") sprengen die feste Spaltenbreite
nicht mehr - stattdessen läuft der Text von selbst wie ein Ticker durch
(siehe .flap-cell--line--scrollable unten), kein manuelles Scrollen nötig. */
overflow: hidden;
}
/* Kein Ausblende-Verlauf mehr an den Rändern - der hat genau den Teil des
Textes verschluckt, den man eigentlich lesen wollte (das Ende des Namens),
sobald der Ticker dort ankam. Die Box schneidet über overflow:hidden
trotzdem sauber ab, ganz ohne Fade. */
/* Der eigentliche Ticker-Effekt: läuft sofort los (keine Wartezeit am
Anfang), hält kurz am Ende an - genau dort, wo der abgeschnittene Teil des
Namens sichtbar wird und man ihn lesen will - und schiebt sich nahtlos
zurück in die nächste Runde. Distanz/Geschwindigkeit kommen als
CSS-Variablen aus app.js, abhängig davon wie weit der Text übersteht. */
.flap-cell--line--scrollable .flap-cell__visual {
display: inline-block;
animation: flap-line-marquee var(--marquee-duration, 4s) ease-in-out infinite;
}
@keyframes flap-line-marquee {
0% { transform: translateX(0); }
35% { transform: translateX(var(--marquee-distance, 0px)); }
70% { transform: translateX(var(--marquee-distance, 0px)); }
100% { transform: translateX(0); }
}
.flap-cell--direction {
@@ -556,6 +608,8 @@ button {
.flap-field--time {
align-items: flex-end;
padding-left: 14px;
border-left: 1px solid var(--line);
}
.flap-char {
@@ -596,6 +650,7 @@ button {
left: 50%;
width: 100vw;
margin-left: -50vw;
box-shadow: inset 0 12px 24px -12px rgba(0,0,0,.6), inset 0 -12px 24px -12px rgba(0,0,0,.6);
}
.vehicle-marker span {
@@ -637,14 +692,16 @@ button {
}
.journey-submit {
background: var(--signal-amber);
color: var(--bg-void);
background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%);
color: #1a1206;
border: none;
border-radius: var(--radius);
padding: 10px 18px;
font-weight: 600;
font-size: 14px;
box-shadow: var(--shadow-glow-amber);
}
.journey-submit:hover { filter: brightness(1.05); }
.journey-card {
background: var(--panel);
@@ -719,7 +776,8 @@ button {
.mode-chip.is-active {
color: var(--signal-amber);
border-color: var(--signal-amber);
background: rgba(245, 166, 35, 0.1);
background: var(--signal-amber-soft);
box-shadow: 0 0 0 1px rgba(245, 166, 35, 0.15);
}
/* ============================================================
@@ -800,32 +858,67 @@ button {
padding: 14px;
}
.remark-card__summary {
font-weight: 600;
/* Einheitliche "Meldungs"-Optik: Icon-Kreis + Textblock, dieselbe
Formsprache wie die Toast-Benachrichtigungen. Wird für Störungen
(rot), Aufzugsstatus (blau) und die Störungslisten genutzt - die
jeweilige Icon-Farbe kommt vom umgebenden Container (.station-disruption,
.elevator-status, .remark-card). */
.notice-item {
display: flex;
align-items: flex-start;
gap: 10px;
}
.notice-item__icon {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
margin-top: 1px;
background: rgba(255, 93, 108, 0.14);
color: var(--signal-red);
margin-bottom: 6px;
font-size: 14px;
}
.remark-card__text {
color: var(--ink);
.notice-item__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.notice-item__body strong {
font-size: 13px;
line-height: 1.5;
margin-bottom: 8px;
color: var(--ink);
}
.remark-card__text a,
.station-disruption__item a,
.elevator-status__item a {
.notice-item__text {
display: block;
color: var(--ink-muted);
font-size: 12.5px;
line-height: 1.5;
}
.notice-item__meta {
display: block;
color: var(--ink-muted);
font-size: 11.5px;
opacity: .8;
}
.notice-item__meta:empty { display: none; }
.station-disruption .notice-item__text a,
.elevator-status .notice-item__text a,
.remark-card .notice-item__text a {
color: var(--signal-amber);
text-decoration: underline;
}
.remark-card__meta {
color: var(--ink-muted);
font-size: 12px;
}
/* ============================================================
Suchergebnis-Icons (Haltestelle/Adresse/POI)
============================================================ */
@@ -957,7 +1050,7 @@ button {
============================================================ */
body { padding-bottom: 0; }
button { transition: border-color .15s ease, background .15s ease, transform .15s ease; }
button { transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease; }
button:active { transform: translateY(1px); }
.app-header {
@@ -1029,9 +1122,23 @@ button:active { transform: translateY(1px); }
padding: 28px;
background: linear-gradient(135deg, var(--panel), var(--panel-raised));
border: 1px solid var(--line);
border-radius: 14px;
border-radius: var(--radius-lg);
margin-bottom: 24px;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
}
.welcome-panel::after {
content: '';
position: absolute;
top: -60%;
right: -20%;
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(245, 166, 35, 0.12), transparent 70%);
pointer-events: none;
}
.welcome-panel > * { position: relative; }
.welcome-panel__eyebrow,
.section-heading__eyebrow {
display: block;
@@ -1056,12 +1163,13 @@ button:active { transform: translateY(1px); }
.more-card,
.favorite-card {
transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease;
box-shadow: var(--shadow-sm);
}
.action-card:hover,
.more-card:hover,
.favorite-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,.28);
box-shadow: var(--shadow-md);
}.action-card--primary { border-color: rgba(245,166,35,.45); }
.action-card__icon { font-size: 23px; }
.action-card strong, .action-card small { display: block; }
@@ -1115,25 +1223,85 @@ button:active { transform: translateY(1px); }
}
.toast {
background: var(--panel-raised);
background: rgba(33, 42, 52, 0.88);
backdrop-filter: blur(12px) saturate(1.3);
-webkit-backdrop-filter: blur(12px) saturate(1.3);
border: 1px solid var(--line);
border-left: 3px solid var(--signal-amber);
color: var(--ink);
border-radius: var(--radius);
padding: 12px 16px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
box-shadow: var(--shadow-lg);
width: 100%;
opacity: 0;
transform: translateY(12px);
transition: opacity .25s ease, transform .25s ease;
position: relative;
overflow: hidden;
display: flex;
align-items: flex-start;
gap: 10px;
}
.toast.is-visible { opacity: 1; transform: translateY(0); }
.toast--warning { border-left-color: var(--signal-red); }
.toast--info { border-left-color: var(--signal-amber); }
.toast--success { border-left-color: var(--signal-green); }
.toast__icon {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
margin-top: 1px;
background: var(--panel-raised);
color: var(--signal-amber);
}
.toast--warning .toast__icon { color: var(--signal-red); background: rgba(255,93,108,.12); }
.toast--info .toast__icon { color: var(--signal-amber); background: rgba(245,166,35,.12); }
.toast--success .toast__icon { color: var(--signal-green); background: rgba(61,220,132,.12); }
.toast__message {
flex: 1;
padding-top: 1px;
}
.toast__close {
flex-shrink: 0;
background: none;
border: 0;
color: var(--ink-muted);
font-size: 18px;
line-height: 1;
width: 20px;
height: 20px;
border-radius: 50%;
margin: -2px -4px 0 0;
}
.toast__close:hover { background: var(--panel-raised); color: var(--ink); }
.toast__progress {
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
background: currentColor;
color: var(--signal-amber);
opacity: .35;
transform-origin: left;
transform: scaleX(1);
transition: transform linear;
}
.toast--warning .toast__progress { color: var(--signal-red); }
.toast--success .toast__progress { color: var(--signal-green); }
.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;
@@ -1147,24 +1315,37 @@ button:active { transform: translateY(1px); }
.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); }
.status-dot {
display: inline-block;
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 14px 13px; }
.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-top: 14px; font-size: 12px; }
.station-disruption__item { line-height: 1.5; }
.station-disruption__item + .station-disruption__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,93,108,.25); }
.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 12px; margin-top: 14px; font-size: 12px; }
.station-disruption .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,93,108,.25); }
/* Aufzugsstatus - eigener, ruhigerer Ton (Info, nicht "akute Störung") */
.elevator-status { background: rgba(79,168,232,.08); border: 1px solid rgba(79,168,232,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-top: 14px; font-size: 12px; }
.elevator-status__item { line-height: 1.5; }
.elevator-status__item + .elevator-status__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(79,168,232,.25); }
.elevator-status__item strong { color: var(--signal-blue); }
.elevator-status { background: rgba(79,168,232,.08); border: 1px solid rgba(79,168,232,.3); color: var(--ink); border-radius: 9px; padding: 12px; margin-top: 14px; font-size: 12px; }
.elevator-status .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(79,168,232,.25); }
.elevator-status .notice-item__icon { background: rgba(79,168,232,.16); color: var(--signal-blue); }
.elevator-status .notice-item__body strong { color: var(--signal-blue); }
.panel-heading { margin-bottom: 22px; }
.panel-heading { margin-bottom: 22px; position: relative; padding-left: 14px; }
.panel-heading::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 3px;
background: linear-gradient(180deg, var(--signal-amber), transparent);
}
.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; }
@@ -1933,7 +2114,7 @@ button:active { transform: translateY(1px); }
}
@media (max-width: 430px) {
.flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
.flap-row { grid-template-columns: 74px minmax(0,1fr) 72px; gap: 12px; }
.flap-field--platform { display: none; }
.flap-field--time { grid-column: 3; }
.notify-callout { flex-wrap: wrap; }