Add files via upload
This commit is contained in:
1 parent
7020ad99fc
commit
9c3e2af209
5 files changed
+741
-164
No files matched your search
+267
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 () => {
|
||||
const container = document.getElementById('nearby-disruptions-list');
|
||||
container.innerHTML = '<div class="board-empty">Standort wird ermittelt…</div>';
|
||||
try {
|
||||
const { lat, lon } = 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>`;
|
||||
}
|
||||
});
|
||||
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 } = 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user