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).
|
Vanilla-JS-Webapp für Live-Abfahrten, Routenplanung und eine Live-Karte im
|
||||||
Stellt VBB-Echtzeitdaten (Abfahrten, Routenplaner, Live-Map) sowie eigene Features
|
VBB-Verbund (Berlin/Brandenburg) mit Anbindung an echte DB-Bahnhöfe. Kein
|
||||||
(Favoriten, Push-Token-Registrierung) über eine REST-API bereit.
|
Build-Schritt, kein Framework – reines HTML/CSS/JS, läuft direkt als
|
||||||
|
statische Website.
|
||||||
|
|
||||||
## 1. Installation
|
## Tech-Stack
|
||||||
|
|
||||||
```bash
|
- Reines HTML5, CSS3, ES2020+ JavaScript (keine Frameworks, kein Bundler)
|
||||||
cd oepnv-navi-backend
|
- [Leaflet](https://leafletjs.com/) für die Live-Karte (per CDN eingebunden)
|
||||||
npm install
|
- Google Fonts: IBM Plex Mono / Sans / Sans Condensed
|
||||||
cp .env.example .env
|
- 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
|
**Wichtig:**
|
||||||
npm start
|
- `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
|
- **Abfahrten**: Live-Abfahrtstafel (VBB + echte DB-Bahnhöfe), Verkehrsmittel-Filter,
|
||||||
curl http://localhost:3000/health
|
Gleiswechsel-Hinweis, Störungen & Aufzugsstatus direkt unter der Tafel
|
||||||
curl "http://localhost:3000/live_navi/stations/search?query=Alexanderplatz"
|
- **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`
|
Moderne Evergreen-Browser (Chrome, Firefox, Safari, Edge). Nutzt u. a.
|
||||||
2. `oepnv-navi-backend.service.example` nach `/etc/systemd/system/oepnv-navi-backend.service`
|
`fetch`, `IntersectionObserver`-freies Vanilla-DOM, Service Worker, Push API,
|
||||||
kopieren und die Platzhalter (`DEIN_USER`, Pfade) anpassen
|
CSS Custom Properties und `dvh`-Einheiten (mit `vh`-Fallback). Kein IE11-Support.
|
||||||
3. Aktivieren:
|
|
||||||
|
|
||||||
```bash
|
## Bekannte Grenzen
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now oepnv-navi-backend
|
|
||||||
sudo systemctl status oepnv-navi-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Einrichtung in Nginx Proxy Manager (NPM)
|
- Die Live-Karte ist für blinde Nutzer naturgemäß eingeschränkt zugänglich
|
||||||
|
(Leaflet-Karten sind visuell) – Abfahrtstafel und Routenplaner liefern
|
||||||
Da Backend-Server und NPM auf unterschiedlichen Hosts im selben LAN laufen, brauchst du
|
dieselben Informationen barrierefrei.
|
||||||
die **lokale IP** des Backend-Servers (z.B. `192.168.1.50`) und den Port aus `.env` (Standard `3000`).
|
- Störungstexte kommen direkt von VBB/HAFAS auf Deutsch – eine Übersetzung
|
||||||
|
ins Englische findet nicht statt (nur die App-Oberfläche selbst ist zweisprachig).
|
||||||
**Falls `serverlele.ddns.net` schon einen Proxy Host für deine Web-App hat:**
|
- Fahrtalarm-Ton und Vollbild-Overlay funktionieren nur bei offenem Tab; bei
|
||||||
|
geschlossenem Browser zeigt ein Push nur die Systembenachrichtigung.
|
||||||
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
|
|
||||||
@@ -299,17 +299,43 @@ const App = (() => {
|
|||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
const ICONS = { success: '✓', warning: '⚠', info: 'ℹ' };
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast--${variant}`;
|
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);
|
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');
|
toast.classList.remove('is-visible');
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 250);
|
||||||
}, 6000);
|
}
|
||||||
|
|
||||||
|
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 ----------
|
// ---------- Browser-Benachrichtigungen an/aus ----------
|
||||||
@@ -377,6 +403,8 @@ const App = (() => {
|
|||||||
btn.classList.toggle('is-active', active);
|
btn.classList.toggle('is-active', active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastKnownLocation = null; // { lat, lon } - einmal ermittelt, für Autostart & Störungen wiederverwendet
|
||||||
|
|
||||||
function getUserLocation() {
|
function getUserLocation() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
@@ -384,7 +412,10 @@ const App = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.geolocation.getCurrentPosition(
|
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)),
|
(err) => reject(new Error('Standort konnte nicht ermittelt werden: ' + err.message)),
|
||||||
{ enableHighAccuracy: true, timeout: 10000 },
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
);
|
);
|
||||||
@@ -658,7 +689,15 @@ const App = (() => {
|
|||||||
} else {
|
} else {
|
||||||
elevatorEl.hidden = false;
|
elevatorEl.hidden = false;
|
||||||
elevatorEl.innerHTML = elevatorItems
|
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('');
|
.join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,7 +709,15 @@ const App = (() => {
|
|||||||
} else {
|
} else {
|
||||||
disruptionEl.hidden = false;
|
disruptionEl.hidden = false;
|
||||||
disruptionEl.innerHTML = otherItems
|
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('');
|
.join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,6 +825,52 @@ const App = (() => {
|
|||||||
row.style.setProperty('--line-color', dep.color || '#8E99A6');
|
row.style.setProperty('--line-color', dep.color || '#8E99A6');
|
||||||
|
|
||||||
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
|
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());
|
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
|
||||||
|
|
||||||
// Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach
|
// Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach
|
||||||
@@ -895,6 +988,10 @@ const App = (() => {
|
|||||||
|
|
||||||
if (tabName === 'disruptions' && !remarksLoadedOnce) {
|
if (tabName === 'disruptions' && !remarksLoadedOnce) {
|
||||||
loadRemarks();
|
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) {
|
if (tabName === 'fares' && !faresLoadedOnce) {
|
||||||
@@ -935,19 +1032,8 @@ const App = (() => {
|
|||||||
document.getElementById('nearby-home').hidden = locations.length === 0;
|
document.getElementById('nearby-home').hidden = locations.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNearbyPreview() {
|
// (Autostart am Standort - inkl. Vorschau der übrigen nahen Haltestellen -
|
||||||
const home = document.getElementById('nearby-home');
|
// übernimmt jetzt autonom autoStartFromLocation() beim App-Start.)
|
||||||
const list = document.getElementById('nearby-home-list');
|
|
||||||
if (!navigator.geolocation || !home || !list) return;
|
|
||||||
list.innerHTML = '<div class="recent-empty">Standort wird ermittelt…</div>';
|
|
||||||
try {
|
|
||||||
const { lat, lon } = await getUserLocation();
|
|
||||||
const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 3 });
|
|
||||||
renderNearbyPreview(locations);
|
|
||||||
} catch (_) {
|
|
||||||
home.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNearbyList(locations, container) {
|
function renderNearbyList(locations, container) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -975,18 +1061,26 @@ const App = (() => {
|
|||||||
function initNearbyDisruptions() {
|
function initNearbyDisruptions() {
|
||||||
const btn = document.getElementById('disruptions-locate');
|
const btn = document.getElementById('disruptions-locate');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', () => loadNearbyDisruptions());
|
||||||
const container = document.getElementById('nearby-disruptions-list');
|
}
|
||||||
container.innerHTML = '<div class="board-empty">Standort wird ermittelt…</div>';
|
|
||||||
try {
|
// Lädt Störungen rund um den aktuellen Standort - wird sowohl vom manuellen
|
||||||
const { lat, lon } = await getUserLocation();
|
// "Standort verwenden"-Button als auch automatisch beim ersten Öffnen des
|
||||||
container.innerHTML = '<div class="board-empty">Suche Störungen in der Nähe…</div>';
|
// Störungen-Tabs aufgerufen (siehe activateTab()), damit man dafür nicht
|
||||||
const { disruptions } = await API.getNearbyDisruptions({ lat, lon, distance: 1000, stops: 6 });
|
// erst extra klicken muss. Nutzt den beim Start bereits ermittelten
|
||||||
renderNearbyDisruptions(disruptions, container);
|
// Standort weiter, statt ihn ein zweites Mal abzufragen, falls vorhanden.
|
||||||
} catch (err) {
|
async function loadNearbyDisruptions() {
|
||||||
container.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
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) {
|
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 lines = d.affectedLines.length ? `Linie${d.affectedLines.length > 1 ? 'n' : ''} ${d.affectedLines.join(', ')}` : '';
|
||||||
const stops = d.affectedStops.length ? ` · nahe ${d.affectedStops.join(', ')}` : '';
|
const stops = d.affectedStops.length ? ` · nahe ${d.affectedStops.join(', ')}` : '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="remark-card__summary">⚠ ${escapeHtml(d.summary || 'Hinweis')}</div>
|
<div class="notice-item">
|
||||||
<div class="remark-card__text">${linkifyRemarkText(d.text || '')}</div>
|
<span class="notice-item__icon" aria-hidden="true">⚠</span>
|
||||||
<div class="remark-card__meta">${escapeHtml(lines)}${escapeHtml(stops)}</div>
|
<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);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
@@ -1069,9 +1168,14 @@ const App = (() => {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'remark-card';
|
card.className = 'remark-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="remark-card__summary">⚠ ${escapeHtml(r.summary || 'Hinweis')}</div>
|
<div class="notice-item">
|
||||||
<div class="remark-card__text">${linkifyRemarkText(r.text || '')}</div>
|
<span class="notice-item__icon" aria-hidden="true">⚠</span>
|
||||||
<div class="remark-card__meta">${escapeHtml(r.company || '')}</div>
|
<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);
|
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() {
|
function init() {
|
||||||
I18N.init();
|
I18N.init();
|
||||||
if (window.Push && Push.isSupported()) Push.registerServiceWorker();
|
if (window.Push && Push.isSupported()) Push.registerServiceWorker();
|
||||||
@@ -1492,7 +1616,7 @@ const App = (() => {
|
|||||||
if (currentStop) toggleFavorite(currentStop);
|
if (currentStop) toggleFavorite(currentStop);
|
||||||
});
|
});
|
||||||
|
|
||||||
restoreLastStop();
|
autoStartFromLocation();
|
||||||
restoreJourneyFromShareLink();
|
restoreJourneyFromShareLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const ModeFilter = (() => {
|
|||||||
express: 'Fern',
|
express: 'Fern',
|
||||||
regional: 'RE/RB',
|
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);
|
const ALL = Object.keys(LABELS);
|
||||||
|
|
||||||
function create(container, storageKey, onChange) {
|
function create(container, storageKey, onChange) {
|
||||||
@@ -38,6 +50,9 @@ const ModeFilter = (() => {
|
|||||||
btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : '');
|
btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : '');
|
||||||
btn.textContent = LABELS[mode];
|
btn.textContent = LABELS[mode];
|
||||||
btn.setAttribute('aria-pressed', selected.has(mode) ? 'true' : 'false');
|
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', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (selected.has(mode)) {
|
if (selected.has(mode)) {
|
||||||
selected.delete(mode);
|
selected.delete(mode);
|
||||||
|
|||||||
+221
-40
@@ -7,10 +7,12 @@
|
|||||||
--bg-void: #0C1116;
|
--bg-void: #0C1116;
|
||||||
--panel: #171E26;
|
--panel: #171E26;
|
||||||
--panel-raised: #212A34;
|
--panel-raised: #212A34;
|
||||||
|
--panel-overlay: #26303C;
|
||||||
--line: #2A323D;
|
--line: #2A323D;
|
||||||
--ink: #EDEFEC;
|
--ink: #EDEFEC;
|
||||||
--ink-muted: #8E99A6;
|
--ink-muted: #8E99A6;
|
||||||
--signal-amber: #F5A623;
|
--signal-amber: #F5A623;
|
||||||
|
--signal-amber-soft: rgba(245, 166, 35, 0.14);
|
||||||
--signal-green: #3DDC84;
|
--signal-green: #3DDC84;
|
||||||
--signal-red: #FF5D6C;
|
--signal-red: #FF5D6C;
|
||||||
--signal-blue: #4FA8E8;
|
--signal-blue: #4FA8E8;
|
||||||
@@ -20,7 +22,13 @@
|
|||||||
--font-label: 'IBM Plex Sans Condensed', sans-serif;
|
--font-label: 'IBM Plex Sans Condensed', sans-serif;
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
|
--radius-lg: 18px;
|
||||||
--header-height: 64px;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--bg-void);
|
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);
|
color: var(--ink);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -100,8 +112,11 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 0 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);
|
border-bottom: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-sm), 0 1px 0 rgba(245, 166, 35, 0.15);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 20;
|
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__text small { display: block; color: var(--ink-muted); font-size: 12px; margin-top: 2px; }
|
||||||
.notify-callout__btn {
|
.notify-callout__btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--signal-amber);
|
background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%);
|
||||||
color: #17110a;
|
color: #1a1206;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -296,6 +311,7 @@ button {
|
|||||||
.tab-button.is-active {
|
.tab-button.is-active {
|
||||||
color: var(--signal-amber);
|
color: var(--signal-amber);
|
||||||
border-bottom-color: var(--signal-amber);
|
border-bottom-color: var(--signal-amber);
|
||||||
|
box-shadow: 0 2px 12px -2px rgba(245, 166, 35, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
@@ -391,14 +407,22 @@ button {
|
|||||||
grid-template-columns: 82px minmax(0, 1fr) 82px 112px;
|
grid-template-columns: 82px minmax(0, 1fr) 82px 112px;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--panel);
|
background: linear-gradient(180deg, var(--panel-overlay), var(--panel) 60%);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-left: 4px solid var(--line-color, var(--line));
|
border-left: 4px solid var(--line-color, var(--line));
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px 16px 14px 13px;
|
padding: 14px 16px 14px 13px;
|
||||||
font-family: var(--font-display);
|
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: flap-row-in .25s ease both;
|
||||||
animation-delay: calc(var(--row-index, 0) * 25ms);
|
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 {
|
@keyframes flap-row-in {
|
||||||
@@ -454,7 +478,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flap-cell--line {
|
.flap-cell--line {
|
||||||
width: max-content;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
min-width: 38px;
|
min-width: 38px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--line-text-color, var(--signal-amber));
|
color: var(--line-text-color, var(--signal-amber));
|
||||||
@@ -462,6 +487,33 @@ button {
|
|||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 16px;
|
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 {
|
.flap-cell--direction {
|
||||||
@@ -556,6 +608,8 @@ button {
|
|||||||
|
|
||||||
.flap-field--time {
|
.flap-field--time {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
padding-left: 14px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flap-char {
|
.flap-char {
|
||||||
@@ -596,6 +650,7 @@ button {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin-left: -50vw;
|
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 {
|
.vehicle-marker span {
|
||||||
@@ -637,14 +692,16 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.journey-submit {
|
.journey-submit {
|
||||||
background: var(--signal-amber);
|
background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%);
|
||||||
color: var(--bg-void);
|
color: #1a1206;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
box-shadow: var(--shadow-glow-amber);
|
||||||
}
|
}
|
||||||
|
.journey-submit:hover { filter: brightness(1.05); }
|
||||||
|
|
||||||
.journey-card {
|
.journey-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -719,7 +776,8 @@ button {
|
|||||||
.mode-chip.is-active {
|
.mode-chip.is-active {
|
||||||
color: var(--signal-amber);
|
color: var(--signal-amber);
|
||||||
border-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;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-card__summary {
|
/* Einheitliche "Meldungs"-Optik: Icon-Kreis + Textblock, dieselbe
|
||||||
font-weight: 600;
|
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);
|
color: var(--signal-red);
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-card__text {
|
.notice-item__body {
|
||||||
color: var(--ink);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-item__body strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
color: var(--ink);
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-card__text a,
|
.notice-item__text {
|
||||||
.station-disruption__item a,
|
display: block;
|
||||||
.elevator-status__item a {
|
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);
|
color: var(--signal-amber);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-card__meta {
|
|
||||||
color: var(--ink-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Suchergebnis-Icons (Haltestelle/Adresse/POI)
|
Suchergebnis-Icons (Haltestelle/Adresse/POI)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -957,7 +1050,7 @@ button {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
body { padding-bottom: 0; }
|
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); }
|
button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -1029,9 +1122,23 @@ button:active { transform: translateY(1px); }
|
|||||||
padding: 28px;
|
padding: 28px;
|
||||||
background: linear-gradient(135deg, var(--panel), var(--panel-raised));
|
background: linear-gradient(135deg, var(--panel), var(--panel-raised));
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: var(--radius-lg);
|
||||||
margin-bottom: 24px;
|
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,
|
.welcome-panel__eyebrow,
|
||||||
.section-heading__eyebrow {
|
.section-heading__eyebrow {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1056,12 +1163,13 @@ button:active { transform: translateY(1px); }
|
|||||||
.more-card,
|
.more-card,
|
||||||
.favorite-card {
|
.favorite-card {
|
||||||
transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease;
|
transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.action-card:hover,
|
.action-card:hover,
|
||||||
.more-card:hover,
|
.more-card:hover,
|
||||||
.favorite-card:hover {
|
.favorite-card:hover {
|
||||||
transform: translateY(-2px);
|
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--primary { border-color: rgba(245,166,35,.45); }
|
||||||
.action-card__icon { font-size: 23px; }
|
.action-card__icon { font-size: 23px; }
|
||||||
.action-card strong, .action-card small { display: block; }
|
.action-card strong, .action-card small { display: block; }
|
||||||
@@ -1115,25 +1223,85 @@ button:active { transform: translateY(1px); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.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: 1px solid var(--line);
|
||||||
border-left: 3px solid var(--signal-amber);
|
border-left: 3px solid var(--signal-amber);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 12px 16px;
|
padding: 12px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
box-shadow: var(--shadow-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px);
|
transform: translateY(12px);
|
||||||
transition: opacity .25s ease, transform .25s ease;
|
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.is-visible { opacity: 1; transform: translateY(0); }
|
||||||
.toast--warning { border-left-color: var(--signal-red); }
|
.toast--warning { border-left-color: var(--signal-red); }
|
||||||
.toast--info { border-left-color: var(--signal-amber); }
|
.toast--info { border-left-color: var(--signal-amber); }
|
||||||
.toast--success { border-left-color: var(--signal-green); }
|
.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 { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
|
||||||
.nearby-preview__item {
|
.nearby-preview__item {
|
||||||
display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left;
|
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-header { margin-bottom: 8px; }
|
||||||
.board-station-name { font-size: 24px; margin-top: 2px; }
|
.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; }
|
.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; }
|
.favorite-toggle { padding: 8px 12px; }
|
||||||
|
|
||||||
.flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px 14px 13px; }
|
.flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px 14px 13px; }
|
||||||
.flap-cell--time { font-size: 17px; }
|
.flap-cell--time { font-size: 17px; }
|
||||||
.flap-cell--direction { font-size: 15px; font-weight: 600; }
|
.flap-cell--direction { font-size: 15px; font-weight: 600; }
|
||||||
.flap-cell--platform { font-size: 14px; }
|
.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 { 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__item { line-height: 1.5; }
|
.station-disruption .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,93,108,.25); }
|
||||||
.station-disruption__item + .station-disruption__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,93,108,.25); }
|
|
||||||
|
|
||||||
/* Aufzugsstatus - eigener, ruhigerer Ton (Info, nicht "akute Störung") */
|
/* 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 { 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__item { line-height: 1.5; }
|
.elevator-status .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(79,168,232,.25); }
|
||||||
.elevator-status__item + .elevator-status__item { margin-top: 8px; padding-top: 8px; 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__item strong { 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 h1 { font-size: 26px; margin-bottom: 6px; }
|
||||||
.panel-heading p { margin: 0; color: var(--ink-muted); font-size: 13px; }
|
.panel-heading p { margin: 0; color: var(--ink-muted); font-size: 13px; }
|
||||||
.live-map { height: min(680px, calc(100vh - 220px)); min-height: 460px; }
|
.live-map { height: min(680px, calc(100vh - 220px)); min-height: 460px; }
|
||||||
@@ -1933,7 +2114,7 @@ button:active { transform: translateY(1px); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 430px) {
|
@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--platform { display: none; }
|
||||||
.flap-field--time { grid-column: 3; }
|
.flap-field--time { grid-column: 3; }
|
||||||
.notify-callout { flex-wrap: wrap; }
|
.notify-callout { flex-wrap: wrap; }
|
||||||
|
|||||||
Reference in new issue
Block a user