Compare commits

...
30 Commits
Author SHA1 Message Date
Claude f8fa1918cd Quellen ohne Last-Modified (Wiener Linien): täglich laden und am Inhalt erkennen, ob es Neues gibt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-23 11:29:06 +02:00
Claude ef67df6e00 Spanien (Renfe + Cercanías), Polen, Norwegen, Dänemark, Österreich (ÖBB + Wiener Linien), Schweden (Key)
- mehrere Quellen je Land (country), Grenzen aus Natural Earth, OSM und Kachelausschnitt erweitert
- Fahrpläne glätten: Unterordner im Zip, aufgefüllte Felder, fehlende parent_station, level_id
- Gattung aus der Zugnummer (ÖBB 'RJ 658'), Fernverkehr auch über Routentyp 101/102/105
- Echtzeit: Verspätung vor Uhrzeit (Renfe), Fahrten ohne Datum, Key in der Adresse (Trafiklab)
- httpx-Logs aus (würden Keys zeigen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-23 09:53:56 +02:00
Claude 650f8dca8c Verspätungsfarbe als Verlauf: bis +2′ grün, gelb 10′, orange 20′, rot 30′, lila 40′, ab 50′ blinkend; Legende als Skala
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 20:42:13 +02:00
Claude be0de5d619 Schweiz: alten Fahrplan ohne Trams nicht mehr laden, nur weil die Echtzeit noch auf ihn verweist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 13:43:07 +02:00
Claude e948a69c32 Lade-Ring dreht sich immer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 13:38:33 +02:00
Claude b5350ae22e Ebenen Regio und S-Bahn getrennt; Lade-Symbol pulsiert bei reduzierter Bewegung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 13:35:39 +02:00
Claude a7daa76535 Ebenen statt Filter (Fern, Nah, Tram, U-Bahn einzeln oder alle); Haltename beim Darüberfahren; Lade-Hinweis nach Server-Neustart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 13:30:42 +02:00
Claude d11cc109f6 Trams und U-Bahnen (CH, DE, NL): eigener Filter, ab Stadtzoom im Ausschnitt; NL-Echtzeit auch für Tram/Metro; Vorausschau ohne 7-fache Neuberechnung; deutsche Steig-Zusätze aus Haltestellennamen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 10:33:45 +02:00
Claude 49860c50f5 Lade-Hinweis blieb hängen, wenn der Server kurz nach einem Echtzeit-Abruf "nicht bereit" meldete
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 09:54:12 +02:00
Claude 4d70cae372 Zugsuche: Linie, Zugnummer oder Ort; Treffer mit Start → Ziel und nächstem Halt, nächstgelegene zuerst
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 09:50:44 +02:00
Claude 811480b4d3 Züge springen nicht mehr zurück bei steigender Verspätung; Ausfälle und Teilausfälle (SKIPPED) berücksichtigt; POI-Symbole ausgeblendet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 09:18:50 +02:00
Claude 1dd39c08db Downloads: Fortschritt alle 30 s im Log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 08:37:21 +02:00
Claude 53b7c24a81 Belgien: offizielle NMBS/SNCB-Feeds mit Echtzeit; Gleis-IDs (8863008_10) auf Bahnhof abgleichen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 08:17:58 +02:00
Claude ae50ac39ac Halte als Punkte auf der Strecke, Ortsnamen ohne Punkt; Doppelte im Ausland entfernt; bereit erst wenn alle Länder berechnet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 08:09:09 +02:00
Claude deaa6aa9cd Filter: Verweise auf nicht kopierte Streckenverläufe leeren (OVapi-Fahrpläne)
Sonst bricht pfaedle mit 'no shape with id' ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 07:33:18 +02:00
Claude 7ece7603a9 Züge folgen dem Gleis statt gerader Linien, Mindesthaltezeit 40 s, osmium renumber
- Server schickt pro Zug die Gleispunkte der nächsten 12 s mit Zeitstempel
  (gerade Stücke auf ~3 m vereinfacht); Browser fährt sie ab, Richtung aus
  dem aktuellen Gleisstück
- Halte mit Ankunft = Abfahrt werden für die Animation auf 40 s verlängert
- OSM-IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 07:29:00 +02:00
Claude d339da5da2 Frankreich, Niederlande, Belgien als weitere Quellen (noch im Test)
- Quellen fr (SNCF), nl (OVapi), be (GTFS.be), Kacheln für alle 5 Länder
- Landesgrenzen für 5 Länder (borders.json), Gitter-Cache für die Prüfung
- Echtzeit-Abgleich auch über Haltestellen-ID (FR, NL), über den Bahnhof
  bei Gleiswechsel
- SNCF: Gattung aus der Trip-ID, Zugnummer aus dem Headsign; NL-Linien gekürzt
- calendar.txt optional (SNCF hat nur calendar_dates)
- OSM je Land zugeschnitten vor pfaedle; beim Zusammenführen je Objekt
  nur die neueste Version (time-filter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 07:06:47 +02:00
Claude 76a8fc0806 Browser lädt nach Updates immer die aktuelle Oberfläche (Cache-Control: no-cache)
Sonst blieb nach einem Update die alte app.js im Cache: neue Buttons
sichtbar, aber ohne Funktion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 06:06:04 +02:00
Claude 8865e7abcf Filter: alle / Nahverkehr / Fernverkehr
Einteilung nach Gattung (Schweiz: route_desc, Deutschland: Präfix der
Linie): ICE, IC, EC, ECE, EN, NJ, RJ(X), TGV, IR, … = Fernverkehr.
Auswahl bleibt im Browser gespeichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-22 06:01:38 +02:00
Claude 085a129a85 Gleiche Fahrplanversion neu aufbereitet -> neu laden; 6 GB RAM empfohlen
pfaedle braucht für die Schweiz mit dem gemeinsamen CH+DE-Schienennetz
bis zu 2,8 GB (gemessen), dazu ~1 GB Server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 21:42:22 +02:00
Claude c07a309045 Deutschland: Karte erst bereit wenn beide Länder geladen, alte Schweizer Daten übernehmen, Hardware-Hinweise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 21:35:09 +02:00
Claude 2a4962c379 WIP: Deutschland als zweite Datenquelle (gtfs.de)
- mehrere Quellen (ch, de) mit je eigenem Fahrplan und Echtzeit-Feed
- OSM CH+DE, mit osmium auf Schienennetz gefiltert, für pfaedle
- Landesgrenze CH gegen doppelte grenzüberschreitende Züge
- Import robust gegen andere Spaltenreihenfolge (gtfs.de)
- Kacheln für CH+DE (Zoom 14)

Noch nicht vollständig getestet (pfaedle auf Deutschland läuft).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 21:27:54 +02:00
Claude 2e3c10a6c4 Ladeanzeige statt Zwischenständen, Echtzeit sofort nach Start, keine Autobahn-Schilder
- /api/status liefert ready, sobald Positionen mit Echtzeit berechnet sind
- Server holt Echtzeit direkt nach dem Laden des Fahrplans (statt nach ~35 s)
- Browser zeigt bis dahin nur 'Züge werden geladen …'
- Statuszeile ohne Technik: 'N Züge unterwegs', ggf. 'Verspätungen zurzeit nicht verfügbar'
- Autobahn-/Strassenschilder (roads_shields) ausgeblendet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 21:02:35 +02:00
Claude 4df29e7e6c Linien-Label flackert nicht mehr, Hover-Kärtchen fährt mit dem Zug mit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 20:50:57 +02:00
Claude a064fccb23 Richtung aus dem Gleisverlauf statt aus der Bewegung, sanftes Bremsen/Anfahren an Halten
- Server berechnet den Kurs aus der Strecke (±25 m) an der Zugposition;
  stimmt dadurch auch im Stillstand (vorher blieb der Pfeil verdreht stehen)
- Trapezprofil: ~150 m vor/nach jedem Halt bremst bzw. beschleunigt der Zug,
  Ankunfts- und Abfahrtszeiten bleiben gleich

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 20:45:12 +02:00
Claude 71d71dabcb Zug-Symbole: kein Quadrat mehr beim Rauszoomen, kleiner bei kleinem Zoom
Der Icon-Rand war breiter als das SDF-Feld und füllte bei kleinem
Zoom das ganze Bild. Rand skaliert jetzt mit der Symbolgrösse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 20:37:32 +02:00
Claude b013753fc3 Pfeil aus Guidos SVG, weiche Drehung entlang der Strecke, Dunkelmodus, lesbare Ortsnamen im Satellit
- Server liefert pro Zug Positionen alle 2 s für die nächsten 10 s,
  der Browser fährt Kurven nach und dreht die Spitze weich (~0,3 s)
- Richtung unter ~2 m Bewegung ignoriert (kein Umklappen am Halt)
- Eigene Icon-Namen (tp-*), die Protomaps-Sprites hatten arrow/dot schon
- Modi Karte / Dunkel / Satellit; im Satellit weisse Schrift mit dunklem Rand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 20:31:46 +02:00
Claude 4447c83ef4 Züge als Tropfen in Fahrtrichtung, nur Linie als Label, Hover mit Zugnummer, Favicon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 20:16:15 +02:00
Claude 30d3127b21 Auf Docker umgestellt
Ein Image (pfaedle + pmtiles + Python), zwei Container per compose:
web (Port 8080) und updater (stündlich Fahrplan, beim ersten Start
Kartenkacheln). install.sh installiert Docker und startet alles.
systemd-Dienste entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 19:24:44 +02:00
Claude 88fd24c22b install.sh: Voraussetzungen prüfen (root, apt, systemd, RAM) und vollständig installieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 15:14:25 +02:00
22 changed files with 1630 additions and 402 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv
.git
data
.env
**/__pycache__
+13
View File
@@ -0,0 +1,13 @@
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT").
# Leer lassen = Karte läuft nur nach Fahrplan, ohne Verspätungen.
TRAINPULSE_RT_KEY=
# Schweden: kostenloser Key von https://developer.trafiklab.se (Projekt anlegen, "GTFS Sweden 3" und
# "GTFS Sweden 3 Realtime" hinzufügen). Ohne Key wird Schweden übersprungen.
TRAINPULSE_SE_KEY=
# Abstand der schwedischen Echtzeit-Abrufe in Sekunden. Bronze-Key: 30 000 Abrufe/Monat, bei zwei
# Regionen reichen 300 s knapp; mit Silver-Key z. B. 60.
TRAINPULSE_SE_RT_INTERVAL=300
# Nur bestimmte Quellen (Standard: alle): ch,de,fr,nl,be,es,es_cer,pl,no,dk,at,at_wl,se
#TRAINPULSE_SOURCES=
+2
View File
@@ -1,3 +1,5 @@
.venv/
__pycache__/
*.pyc
data/
.env
+28
View File
@@ -0,0 +1,28 @@
# pfaedle: rechnet aus OpenStreetMap die echten Streckenverläufe
FROM debian:trixie AS pfaedle
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git cmake g++ make libzip-dev zlib1g-dev libbz2-dev \
&& git clone --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle /src \
&& cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release \
&& make -C /build -j"$(nproc)" pfaedle \
&& make -C /build install
# pmtiles: schneidet die Kartenkacheln für die Region aus
FROM golang:1.25-trixie AS pmtiles
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
FROM python:3.13-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 osmium-tool \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
COPY --from=pmtiles /go/bin/go-pmtiles /usr/local/bin/pmtiles
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY trainpulse trainpulse
COPY web web
COPY tests tests
ENV TRAINPULSE_DATA=/data PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["uvicorn", "trainpulse.server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
+89 -34
View File
@@ -1,61 +1,99 @@
# TrainPulse – Schweiz
# TrainPulse
Live-Karte aller Züge in der Schweiz: Position entlang der echten Strecke, Linie, Zugnummer,
Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte
(OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien, den Niederlanden, Österreich, Spanien,
Polen, Dänemark, Norwegen und Schweden, dazu Trams und U-Bahnen (CH, DE, NL, AT/Wien, DK, NO, SE):
Position entlang der echten Strecke,
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
## Wie es funktioniert
```
opentransportdata.swiss ──GTFS (Mo+Do)──▶ trainpulse-update (stündlich)
├─ Filter: nur Bahn (route_type 100–199)
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe
└─▶ /var/lib/trainpulse/feeds/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──▶ trainpulse (Webserver, Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
Fahrpläne (GTFS) ─────────────────────────┐
CH, DE, FR, NL, BE, AT (+Wien), ES (+ ├▶ updater-Container (stündlich)
Cercanías), PL, DK, NO, SE (Key) │ ├─ Filter: Bahn, Tram, U-Bahn (ohne Bus/Schiff)
OpenStreetMap (Geofabrik, 11 Länder) ─────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten
├─ pfaedle: Streckenverläufe
└─▶ data/feeds/<quelle>/<version>/
Echtzeit (GTFS-RT) ───────────────────────┐
CH (Key), DE, FR, NL, BE, ES, PL, NO └▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
```
Es gibt in der Schweiz **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan
und gemeldeter Verspätung berechnet und auf den Streckenverlauf gelegt.
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
Verspätung berechnet und auf den Streckenverlauf gelegt.
Die Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt jeder
Fahrplan seine Züge nur im eigenen Land; im übrigen Ausland (Österreich, Italien, …) zeigen sie alle
(`trainpulse/borders.json`: Schweiz aus OpenStreetMap, sonst Natural Earth).
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
sich die Echtzeitdaten gerade beziehen (`feed_version`), und schaltet automatisch mit um.
## Installation auf Proxmox
## Installation auf Proxmox (Docker)
TrainPulse läuft als zwei Docker-Container aus demselben Image:
`web` (Webserver, Port 8080) und `updater` (holt stündlich neue Fahrpläne und beim ersten Start
die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`.
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **4 GB RAM** (pfaedle braucht beim Aufbereiten bis ~2 GB, der Server ~0,5 GB), 20 GB Disk, Netz per DHCP.
2. **Projekt in den Container kopieren**, z. B. `scp trainpulse.zip root@<ip>:` und dort
`apt install -y unzip && unzip trainpulse.zip`.
3. **Installieren:** `bash trainpulse/deploy/install.sh`
Baut pfaedle, lädt die Kartenkacheln für die Schweiz, richtet die Dienste ein und startet
die erste Fahrplan-Aufbereitung im Hintergrund (`journalctl -fu trainpulse-update`).
4. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **8 GB RAM** (Server ~4 GB, pfaedle beim
Aufbereiten bis ~2,5 GB, beim Fahrplanwechsel kurz beide Versionen), **60 GB Disk**
(Kartenkacheln ~21 GB, Fahrpläne ~9 GB in je zwei Versionen, beim monatlichen OSM-Update
kurzzeitig bis ~5 GB Download), Netz per DHCP.
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
im LXC nicht.
2. **Installieren** (im Container als root):
```bash
apt install -y git
git clone https://git.scarriffle.com/Scarriffle/Trainpulse.git
bash Trainpulse/deploy/install.sh
```
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
Der erste Start dauert ca. 1,5–2 Stunden: Kartenkacheln (~21 GB) und OpenStreetMap für alle
elf Länder (~21 GB) herunterladen, dann alle Fahrpläne aufbereiten.
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
`/etc/trainpulse.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `systemctl restart trainpulse`.
`Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
4. **Schweden freischalten** (optional): Auf <https://developer.trafiklab.se> ein Projekt anlegen,
*GTFS Sweden 3* und *GTFS Sweden 3 Realtime* hinzufügen und den Key in `.env` bei
`TRAINPULSE_SE_KEY=` eintragen. Ohne Key fehlt Schweden.
Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach
hinter deinen bestehenden Reverse Proxy hängen.
Ein Update des Programms: neue Version in den Container kopieren, `install.sh` nochmals ausführen.
**Update:** `git pull && bash deploy/install.sh` – baut das Image neu, Daten bleiben.
## Nützliche Befehle
## Nützliche Befehle (im Ordner `Trainpulse`)
| Was | Befehl |
|---|---|
| Status | `curl localhost:8080/api/status` |
| Logs Webserver | `journalctl -fu trainpulse` |
| Logs Fahrplan-Update | `journalctl -fu trainpulse-update` |
| Fahrplan sofort neu aufbereiten | `sudo -u trainpulse env $(cat /etc/trainpulse.env) /opt/trainpulse/.venv/bin/python -m trainpulse.prepare --force` |
| Selbsttest | `/opt/trainpulse/.venv/bin/python tests/test_core.py` (im Projektordner) |
| Logs Webserver | `docker compose logs -f web` |
| Logs Fahrplan-Update | `docker compose logs -f updater` |
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --source de --force` (oder `ch`) |
| Selbsttest | `docker compose exec web python tests/test_core.py` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
## Datenquellen und Lizenzen
Die Hinweise erscheinen unten rechts auf der Karte.
- Fahrplan & Echtzeit: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
- Frankreich: SNCF über [transport.data.gouv.fr](https://transport.data.gouv.fr/datasets/horaires-sncf) (ODbL), Fahrplan und Echtzeit
- Niederlande: [OVapi / Stichting OpenGeo](https://gtfs.ovapi.nl), Fahrplan und Echtzeit
- Belgien: NMBS/SNCB [Open Data](https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data) (HAFAS-Feeds), Fahrplan und Echtzeit
- Spanien: [Renfe](https://data.renfe.com) (Fern-/Regionalverkehr und Cercanías), Fahrplan und Echtzeit
- Polen: [mkuran.pl](https://mkuran.pl/gtfs/) (alle Bahnbetreiber, Daten von PKP PLK), Fahrplan und Echtzeit (CC BY 4.0)
- Norwegen: [Entur](https://developer.entur.org) (NLOD), Fahrplan; Echtzeit für Vy, Flytoget, Go-Ahead, Oslo, Bergen, Trondheim
- Dänemark: [Rejseplanen](https://www.rejseplanen.dk) (alle Verkehrsmittel), nur Fahrplan
- Österreich: [ÖBB](https://data.oebb.at) und [Wiener Linien](https://www.data.gv.at) (CC BY 4.0), nur Fahrplan
- Schweden: [Trafiklab](https://www.trafiklab.se) GTFS Sweden 3 (Key nötig), Echtzeit nur Stockholm und Skåne
- Landesgrenzen: [Natural Earth](https://www.naturalearthdata.com) (gemeinfrei) und OpenStreetMap
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
@@ -63,11 +101,28 @@ Die Hinweise erscheinen unten rechts auf der Karte.
## Bekannte Grenzen
- Nur Bahn. Busse, Trams und Schiffe sind im Fahrplan, werden aber noch herausgefiltert.
- Der Schweizer Fahrplan enthält auch einige ausländische Züge (z. B. TGV, TER); deren Strecke
ausserhalb der Schweiz ist nur Luftlinie, weil der OSM-Ausschnitt nur die Schweiz abdeckt.
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
für ein paar Sekunden minimal ab.
- Busse, Schiffe und Seilbahnen werden nicht angezeigt. Trams und U-Bahnen erscheinen erst ab
Stadtzoom (Stufe 11) und nur im Kartenausschnitt; die Suche findet sie nur dort.
- Trams/U-Bahnen gibt es für CH, DE, NL, Wien, DK, NO und SE. Belgien (Brüssel STIB/MIVB),
Frankreich (RATP, TCL …), Spanien (Metro Madrid …), Polen und Österreich ausser Wien haben eigene
Datensätze, die noch fehlen.
- Ohne offene Echtzeit (grau): Österreich, Dänemark, Schweden ausser Stockholm/Skåne. Der
österreichische ÖBB-Fahrplan erscheint nur einmal pro Jahr; Baustellenfahrpläne fehlen.
- Der polnische Echtzeit-Feed bleibt laut Betreiber ein- bis zweimal täglich über eine Stunde stehen.
- Tschechien, Italien und Grossbritannien fehlen: keine offenen Fahrplandaten (CZ, IT) bzw. kein
GTFS, nur ein eigenes Format mit Registrierung (GB). In Berlin liefert der freie deutsche Echtzeit-Feed
keine Verspätungen für U-Bahn und Tram.
- Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
werden nicht angezeigt, sobald sie die Schweiz verlassen.
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
wird als Ziel der letzte Halt.
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
rund 9 GB Datenverkehr pro Tag; die Niederlande (Züge + Trams/Metro, ~6 MB/min) ~8 GB, Polen
(~5 MB alle 2 min) ~3,5 GB, die übrigen zusammen ~5 GB.
- Belgien: die Feed-Adressen unter sncb-opendata.hafas.de sind nicht offiziell dokumentiert und
laufen evtl. aus (die NMBS stellt auf data.belgianmobility.io um). Dann neue Adresse als
`TRAINPULSE_BE_RT_URL` (und ggf. `TRAINPULSE_BE_RT_KEY`) in `.env` eintragen.
- Paris (RER/Transilien) ist nicht enthalten – das ist ein eigener Datensatz.
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
- In der Nacht des Fahrplanwechsels (2. Sonntag im Dezember) fehlen bis zum nächsten
stündlichen Lauf die Züge.
+21
View File
@@ -0,0 +1,21 @@
services:
web:
build: .
image: trainpulse
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- ./data:/data
# holt stündlich neue Fahrpläne (und beim ersten Start die Kartenkacheln)
updater:
build: .
image: trainpulse
depends_on: [web]
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data
command: ["sh", "-c", "while true; do python -m trainpulse.prepare; sleep 3600; done"]
+29 -64
View File
@@ -1,79 +1,44 @@
#!/usr/bin/env bash
# TrainPulse – Installation in einem Debian-LXC (Proxmox). Im Container als root:
# TrainPulse – Installation mit Docker (z. B. in einem Debian-LXC auf Proxmox). Als root:
# bash deploy/install.sh
# Erneut ausführen = Update des Programmcodes (Daten, Karte und Einstellungen bleiben).
# Erneut ausführen = Update (Image neu bauen, Daten und Einstellungen bleiben).
set -euo pipefail
cd "$(dirname "$0")/.."
APP=/opt/trainpulse
DATA=/var/lib/trainpulse
SRC=$(cd "$(dirname "$0")/.." && pwd)
BBOX=5.8,45.7,10.6,47.9 # Schweiz mit etwas Rand
[ "$(id -u)" = 0 ] || { echo "Bitte als root ausführen" >&2; exit 1; }
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
disk=$(df -Pm . | awk 'NR==2 {print $4}')
[ "$disk" -ge 50000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~60 GB" >&2
[ "$mem" -ge 7500 ] || echo "WARNUNG: nur ${mem} MB RAM – empfohlen sind 8 GB (Server ~4 GB + Aufbereitung)" >&2
echo "==> Pakete"
apt-get update -qq
apt-get install -y -qq python3-venv git cmake g++ make libzip-dev zlib1g-dev libbz2-dev curl ca-certificates >/dev/null
echo "==> Programm nach $APP"
id trainpulse &>/dev/null || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin trainpulse
mkdir -p "$APP" "$DATA/tiles"
rm -rf "$APP/trainpulse" "$APP/web"
cp -r "$SRC/trainpulse" "$SRC/web" "$SRC/requirements.txt" "$APP/"
[ -d "$APP/.venv" ] || python3 -m venv "$APP/.venv"
"$APP/.venv/bin/pip" install -q --upgrade -r "$APP/requirements.txt"
if ! command -v pfaedle >/dev/null; then
echo "==> pfaedle bauen (Streckenverläufe aus OpenStreetMap, dauert ein paar Minuten)"
tmp=$(mktemp -d)
git clone -q --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle "$tmp/src"
cmake -S "$tmp/src" -B "$tmp/build" -DCMAKE_BUILD_TYPE=Release >/dev/null
make -C "$tmp/build" -j"$(nproc)" >/dev/null
make -C "$tmp/build" install >/dev/null
rm -rf "$tmp"
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
echo "==> Docker installieren (offizielles Skript von get.docker.com)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates >/dev/null
curl -fsSL https://get.docker.com | sh
fi
if ! command -v pmtiles >/dev/null; then
echo "==> pmtiles-Werkzeug"
url=$(curl -fsSL https://api.github.com/repos/protomaps/go-pmtiles/releases/latest | python3 -c \
'import json,sys; print(next(a["browser_download_url"] for a in json.load(sys.stdin)["assets"] if a["name"].endswith("Linux_x86_64.tar.gz")))')
curl -fsSL "$url" | tar xz -C /usr/local/bin pmtiles
fi
if [ ! -s "$DATA/tiles/region.pmtiles" ]; then
echo "==> Kartenkacheln (OpenStreetMap via Protomaps) für $BBOX"
for i in 1 2 3 4 5 6 7; do
d=$(date -d "-$i day" +%Y%m%d)
if pmtiles extract "https://build.protomaps.com/$d.pmtiles" "$DATA/tiles/region.tmp.pmtiles" --bbox=$BBOX --maxzoom=15; then
mv "$DATA/tiles/region.tmp.pmtiles" "$DATA/tiles/region.pmtiles"
break
fi
done
[ -s "$DATA/tiles/region.pmtiles" ] || { echo "Kartenkacheln konnten nicht geladen werden" >&2; exit 1; }
fi
if [ ! -f /etc/trainpulse.env ]; then
cat > /etc/trainpulse.env <<EOF
TRAINPULSE_DATA=$DATA
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT")
TRAINPULSE_RT_KEY=
if ! docker run --rm hello-world >/dev/null 2>&1; then
cat >&2 <<'EOF'
Docker ist installiert, kann aber keine Container starten.
In einem Proxmox-LXC: Container herunterfahren, unter Optionen → Features
"Nesting" und "keyctl" aktivieren, Container starten, Skript nochmals ausführen.
EOF
chmod 640 /etc/trainpulse.env
chgrp trainpulse /etc/trainpulse.env
exit 1
fi
chown -R trainpulse:trainpulse "$DATA"
echo "==> Dienste"
cp "$SRC"/deploy/trainpulse.service "$SRC"/deploy/trainpulse-update.service "$SRC"/deploy/trainpulse-update.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now trainpulse-update.timer
systemctl enable trainpulse.service
systemctl restart trainpulse.service
systemctl start --no-block trainpulse-update.service
[ -f .env ] || cp .env.example .env
mkdir -p data
echo "==> Image bauen und starten (das erste Mal dauert der Bau von pfaedle ein paar Minuten)"
docker compose up -d --build
ip=$(hostname -I | awk '{print $1}')
cat <<EOF
Fertig. Die Karte läuft unter http://$ip:8080
Der erste Fahrplan wird jetzt im Hintergrund geladen und aufbereitet (ca. 10–15 min).
Fortschritt: journalctl -fu trainpulse-update
Echtzeit: API-Key in /etc/trainpulse.env eintragen, dann systemctl restart trainpulse
Der Updater lädt jetzt Kartenkacheln (~21 GB), OpenStreetMap und die Fahrpläne von elf Ländern
und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 1,5–2 Stunden.
Fortschritt: docker compose logs -f updater
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
EOF
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=TrainPulse: neuen Fahrplan holen und Streckenverläufe berechnen
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/python -m trainpulse.prepare
Nice=10
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=TrainPulse: stündlich auf neuen Fahrplan prüfen
[Timer]
OnBootSec=2min
OnCalendar=*-*-* *:20:00
Persistent=true
[Install]
WantedBy=timers.target
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=TrainPulse Webserver
After=network-online.target
Wants=network-online.target
[Service]
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080 --proxy-headers
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+182 -7
View File
@@ -1,5 +1,7 @@
"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)"""
import csv
import datetime as dt
import io
import json
import sys
import tempfile
@@ -36,15 +38,33 @@ SHAPE = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence,shape_dist_travele
DAY = dt.date(2026, 10, 21)
def make_feed(tmp: Path, shapes=True) -> Feed:
def german_style(files: dict) -> dict:
"""Wie gtfs.de: Spalten in anderer Reihenfolge, kein Fahrtziel/keine Zugnummer, Version 'latest-free'."""
out = {}
for n, c in files.items():
rows = list(csv.reader(io.StringIO(c)))
if n == "trips.txt":
rows = [r[:3] for r in rows] # route_id, service_id, trip_id
if n == "feed_info.txt":
rows[1][1] = "latest-free"
rows = [r[::-1] for r in rows] # Spalten rückwärts
out[n] = "".join(",".join(r) + "\n" for r in rows)
return out
def make_feed(tmp: Path, shapes=True, files=FILES) -> Feed:
tmp.mkdir(parents=True, exist_ok=True)
zp = tmp / "in.zip"
with zipfile.ZipFile(zp, "w") as z:
for n, c in FILES.items():
z.writestr(n, "" + c) # wie die echten Schweizer Dateien: mit BOM
meta = prepare.filter_rail(zp, tmp / "rail")
assert meta["feed_version"] == "20261017" and meta["trips"] == 1 and meta["stop_times"] == 3, meta
for n, c in files.items():
z.writestr(n, "\ufeff" + c) # wie die echten Schweizer Dateien: mit BOM
meta = prepare.filter_rail(zp, tmp / "rail", "20261020")
assert meta["feed_version"] == ("20261017" if files is FILES else "20261020"), meta
assert meta["trips"] == 1 and meta["stop_times"] == 3, meta
if shapes:
trips = (tmp / "rail" / "trips.txt").read_text()
assert "SH1" not in trips # Verweise auf nicht kopierte shapes werden geleert
(tmp / "rail" / "trips.txt").write_text(trips.replace("812,", "812,SH1")) # wie pfaedle
(tmp / "rail" / "shapes.txt").write_text(SHAPE)
prepare.pack_shapes(tmp / "rail")
assert not (tmp / "rail" / "shapes.txt").exists()
@@ -93,7 +113,33 @@ def test_all():
assert len(v) == 1 and v[0]["properties"]["delay"] is None
lon, lat = v[0]["geometry"]["coordinates"]
assert lat < 47.0 and abs(lon - 8.05) < 0.01, (lon, lat) # halbe Fahrzeit = Umweg-Scheitelpunkt
assert v[0]["properties"]["next"] == "Beta"
assert v[0]["properties"]["next"] == "Beta" and v[0]["properties"]["fv"] is True # IC = Fernverkehr
assert abs(v[0]["properties"]["heading"] - 90) < 15, v[0]["properties"]["heading"] # Scheitel: Richtung Osten
assert f.vehicles(at(8, 11), {})[0]["properties"]["heading"] is not None # auch im Stillstand
# Gleisverlauf für den Browser: folgt der Kurve über den Umweg-Scheitel, Zeiten steigen an
tr = f.track(0, [3000, 4500, 6000], 2)
assert tr[0][2] == 0 and tr[-1][2] == 4 and [p[2] for p in tr] == sorted(p[2] for p in tr)
assert any(abs(p[0] - 8.05) < 1e-4 and abs(p[1] - 46.98) < 1e-4 for p in tr), tr # Scheitelpunkt drin
assert len(f.track(0, [5000, 5000, 5000], 2)) == 3 # steht: Punkte bleiben (Zeit läuft)
assert tr[0][3] == 3000 and tr[-1][3] == 6000 # Strecken-km für "nie rückwärts" im Browser
v = f.vehicles(at(8, 18), {}, (60, 120, 180)) # Vorausschau = einzelne Abfragen, endet mit der Fahrt
assert v[0]["_ahead"] == [f.vehicles(at(8, m), {})[0]["_dist"] for m in (19, 20)] and not f.vehicles(at(8, 21), {})
v = f.vehicles(at(8, 2), {}, (60, 120))
assert v[0]["_ahead"] == [f.vehicles(at(8, m), {})[0]["_dist"] for m in (3, 4)]
# Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben
from trainpulse.feed import with_min_dwell
pa, pd = with_min_dwell([0, 600, 1200], [0, 600, 1200])
assert pa[1] == 580 and pd[1] == 620 and pa[0] == 0 and pd[2] == 1200
# Anfahren/Bremsen: symmetrisch, am Anfang/Ende langsamer als linear, Gesamtstrecke stimmt
from trainpulse.feed import travelled
assert travelled(8000, 600, 0) == 0 and abs(travelled(8000, 600, 600) - 8000) < 1e-6
assert abs(travelled(8000, 600, 300) - 4000) < 1e-6
assert travelled(8000, 600, 10) < 8000 * 10 / 600 / 2
assert abs(travelled(8000, 600, 590) - (8000 - travelled(8000, 600, 10))) < 1e-6
assert all(travelled(200, 60, x) <= travelled(200, 60, x + 1) for x in range(60)) # kurz: nie rückwärts
assert f.vehicles(at(8, 11), {})[0]["geometry"]["coordinates"] == [8.1, 47.0] # steht in Beta
ver, rt = realtime.parse(rt_feed(delay_b=300), f.trip_index)
@@ -103,22 +149,151 @@ def test_all():
v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort
assert v and v[0]["properties"]["delay"] == 300
# Frankreich/Niederlande: nur stop_id, kein stop_sequence
fm = pb.FeedMessage()
fm.header.gtfs_realtime_version = "2.0"
e = fm.entity.add(id="1")
e.trip_update.trip.trip_id, e.trip_update.trip.start_date = "T1", "20261021"
st = e.trip_update.stop_time_update.add(stop_id="B_7") # Belgien: Gleis als Suffix
st.arrival.delay = st.departure.delay = 300
_, rt = realtime.parse(fm.SerializeToString(), f.trip_index)
assert rt[("T1", "20261021")][1][0][0] is None
assert f.vehicles(at(8, 14), rt)[0]["properties"]["delay"] == 300
_, rt = realtime.parse(rt_feed(cancel=True), f.trip_index)
assert f.vehicles(at(8, 5), rt) == []
def skipped(*seqs): # Ausfall einzelner Halte (so meldet Deutschland auch ganze Ausfälle)
fm = pb.FeedMessage()
fm.header.gtfs_realtime_version = "2.0"
e = fm.entity.add(id="1")
e.trip_update.trip.trip_id, e.trip_update.trip.start_date = "T1", "20261021"
for q in seqs:
e.trip_update.stop_time_update.add(stop_sequence=q).schedule_relationship = \
pb.TripUpdate.StopTimeUpdate.SKIPPED
return realtime.parse(fm.SerializeToString(), f.trip_index)[1]
assert f.vehicles(at(8, 5), skipped(1, 2, 3)) == [] # alle Halte fallen aus = Zug fällt aus
rt = skipped(3) # endet vorzeitig in Beta
assert f.vehicles(at(8, 5), rt) and f.vehicles(at(8, 15), rt) == []
assert [x["skipped"] for x in f.trip_detail("20261021", 0, at(8, 5), rt)["stops"]] == [False, False, True]
assert f.trip_detail("20261021", 0, at(8, 5), skipped(1, 2, 3))["canceled"]
assert f.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates
_, rt = realtime.parse(rt_feed(delay_b=120), f.trip_index)
det = f.trip_detail("20261021", 0, at(8, 13), rt)
assert [s["name"] for s in det["stops"]] == ["Alpha", "Beta", "Gamma"]
assert (det["stops"][1]["lon"], det["stops"][1]["lat"]) == (8.1, 47.0) # Halt auf der Strecke
assert det["stops"][1]["arr"] == "08:10" and det["stops"][1]["arr_delay"] == 120
assert det["stops"][0]["passed"] and not det["stops"][1]["passed"]
assert day_start(DAY) == at(0, 0)
g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie
assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0]
print("alle Tests ok")
de = make_feed(Path(d) / "de", shapes=False, files=german_style(FILES)) # Format von gtfs.de
v = de.vehicles(at(8, 5), {})
assert len(v) == 1 and v[0]["properties"]["to"] == "Gamma" and v[0]["properties"]["nr"] == ""
assert de.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates
from trainpulse.feed import STOP_SUFFIX, long_distance, route_mode, sbahn
assert all(sbahn(c, l) for c, l in (("S", "S5"), ("SN", "SN7"), ("", "S1"), ("", "S 5"), ("", "S61"), ("", "S")))
assert not any(sbahn(c, l) for c, l in (("", "RE1"), ("", "RB26"), ("TER", "K3"), ("", "SPR"), ("", "SE10"), ("", "STB")))
assert [STOP_SUFFIX.sub("", n) for n in ("S+U Rathaus Spandau Bstggl.2", "Zingster Str., Ausstieg Gl.3-5",
"Virchow-Klinikum, Ausstieg", "Zürich HB", "Bahnhof, Oranienburg")] == \
["S+U Rathaus Spandau", "Zingster Str.", "Virchow-Klinikum", "Zürich HB", "Bahnhof, Oranienburg"]
assert long_distance("", "ICE 75") and long_distance("IR", "IR36") and long_distance("", "ECE 20")
assert not long_distance("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3")
assert not long_distance("TER", "K1") and not long_distance("", "")
assert [route_mode(t) for t in ("2", "106", "0", "900", "1", "401", "3", "700", "1400")] == \
["rail", "rail", "tram", "tram", "subway", "subway", None, None, None] # Bus, Standseilbahn: nein
assert long_distance("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "")
assert long_distance("", "AVE") and long_distance("", "EIP") and long_distance("", "F5") # ES, PL, Norwegen
assert not long_distance("", "F") and sbahn("", "F", "109") # Kopenhagen S-tog F
from trainpulse.feed import LINE_PREFIX
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
from trainpulse.border import country, in_switzerland, shows
assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz
assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave)
places = {(2.355, 48.881): "fr", (4.357, 50.836): "be", (4.900, 52.379): "nl", (13.369, 52.525): "de",
(8.540, 47.378): "ch", (6.13, 49.61): None, (14.51, 46.05): None, # Paris … Luxemburg, Ljubljana
(11.40, 47.26): "at", (16.37, 48.21): "at", (-3.69, 40.41): "es", (21.01, 52.23): "pl",
(10.75, 59.91): "no", (12.565, 55.672): "dk", (18.06, 59.33): "se"} # Innsbruck, Wien, Madrid, …
for (x, y), c in places.items():
assert country(x, y) == c, (x, y, country(x, y))
both = {"ch", "de"}
assert shows("ch", both, 8.54, 47.378) and not shows("de", both, 8.54, 47.378)
assert shows("de", both, 14.51, 46.05) and shows("ch", both, 14.51, 46.05) # Ausland: beide
assert shows("de", both, 2.355, 48.881) # Frankreich ohne eigenen Fahrplan -> DE zeigt
def test_feed_choice():
"""Echtzeit verweist auf alten Fahrplan ohne Trams -> trotzdem den neuen (mit Trams) laden."""
from trainpulse import server
with tempfile.TemporaryDirectory() as d:
for v, rtype in (("20261017", "102"), ("20261021", "900")):
p = Path(d) / "ch" / v
p.mkdir(parents=True)
(p / "meta.json").write_text("{}")
(p / "routes.txt").write_text(f"route_id,route_type\nR,{rtype}\n")
loaded = []
class Stub:
def __init__(self, path, **kw):
loaded.append(path.name)
self.version, self.meta, self.trip_ids = path.name, {}, []
def covers(self, day):
return True
old = server.C.FEEDS_DIR, server.Feed, server.compute_positions, dict(server.S.sources)
try:
server.C.FEEDS_DIR, server.Feed, server.compute_positions = Path(d), Stub, lambda: None
server.S.sources = {"ch": server.Source("ch")}
server.S.sources["ch"].rt_version = "20261017"
server.watch_feeds()
finally:
server.C.FEEDS_DIR, server.Feed, server.compute_positions, server.S.sources = old
assert loaded == ["20261021"], loaded
def test_new_countries():
"""Renfe Cercanías: aufgefüllte Felder; ÖBB: Unterordner und Gattung nur in der Zugnummer;
Renfe-Echtzeit ohne Datum."""
with tempfile.TemporaryDirectory() as d:
files = dict(FILES)
files["routes.txt"] = ("route_id ,agency_id,route_short_name,route_long_name,route_desc,route_type\n"
"R1 ,11,A10-1,,,2 \nB1,11,31,,B,700\n")
files["trips.txt"] = files["trips.txt"].replace("R1,S,T1,Ziel,812,", "R1 ,S,T1,Ziel,RJ 658,")
files["calendar.txt"] = files["calendar.txt"].replace("end_date", "end_date ") # auch nur im Kopf
files["stops.txt"] = "\n".join(l + (",level_id" if i == 0 else ",Level 0") if l else l
for i, l in enumerate(files["stops.txt"].split("\n"))) # ÖBB
zp = Path(d) / "in.zip"
with zipfile.ZipFile(zp, "w") as z:
for n, c in files.items():
z.writestr("GTFS_Fahrplan_2026/" + n, c)
flat = prepare.normalize_zip(zp, Path(d))
meta = prepare.filter_rail(flat, Path(d) / "rail", "20261020")
assert meta["trips"] == 1, meta # Route trotz "R1 " gefunden
assert "Level 0" not in (Path(d) / "rail" / "stops.txt").read_text() # levels.txt fehlt -> Verweis leer
(Path(d) / "rail" / "meta.json").write_text(json.dumps(meta))
f = Feed(Path(d) / "rail")
p = f.vehicles(at(8, 5), {})[0]["properties"]
assert (p["line"], p["nr"], p["fv"], p["sb"]) == ("RJ", "658", True, False), p
assert f.trip_detail("20261021", 0, at(8, 5), {})["line"] == "RJ"
fm = pb.FeedMessage()
fm.header.gtfs_realtime_version = "2.0"
e = fm.entity.add(id="1")
e.trip_update.trip.trip_id = "T1" # ohne start_date (Renfe)
st = e.trip_update.stop_time_update.add(stop_id="B")
st.arrival.delay = st.departure.delay = 300
_, rt = realtime.parse(fm.SerializeToString(), f.trip_index)
assert f.vehicles(at(8, 14), rt)[0]["properties"]["delay"] == 300
assert prepare.normalize_zip(flat, Path(d)) == flat # schon sauber: unverändert
if __name__ == "__main__":
test_all()
test_feed_choice()
test_new_countries()
print("alle Tests ok")
+67
View File
@@ -0,0 +1,67 @@
"""Landesgrenzen, damit jeder Fahrplan seine Züge nur im eigenen Land zeigt – sonst wären
grenzüberschreitende Züge doppelt auf der Karte (z. B. ICE Basel–Frankfurt in CH- und DE-Daten).
Schweiz aus OpenStreetMap (~400 m genau, mit Büsingen und Campione als Löcher), die übrigen
Länder aus Natural Earth 1:10 Mio. (gemeinfrei), auf ~400 m vereinfacht."""
import json
from pathlib import Path
_RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
ORDER = ["ch", "de", "fr", "be", "nl", "at", "dk", "es", "no", "pl", "se"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt
def _bbox(ring):
xs, ys = [p[0] for p in ring], [p[1] for p in ring]
return min(xs), min(ys), max(xs), max(ys)
_POLYS = {code: [(_bbox(p[0]), p) for p in _RAW[code]] for code in ORDER if code in _RAW}
def _in_ring(x: float, y: float, ring) -> bool:
inside = False
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
inside = not inside
return inside
def _country(x: float, y: float) -> str | None:
for code, polys in _POLYS.items():
for (x0, y0, x1, y1), (outer, *holes) in polys:
if x0 <= x <= x1 and y0 <= y <= y1 and _in_ring(x, y, outer) \
and not any(_in_ring(x, y, h) for h in holes):
return code
return None
_cache: dict = {}
def country(lon: float, lat: float) -> str | None:
"""Ländercode (ch, de, fr, be, nl) oder None für alle anderen Länder."""
key = (round(lon / CELL), round(lat / CELL))
if key not in _cache:
if len(_cache) > 500_000:
_cache.clear()
_cache[key] = _country(key[0] * CELL, key[1] * CELL)
return _cache[key]
def bbox(code: str, margin: float = 0.6) -> str:
"""Ausschnitt eines Landes (+ ~50 km Rand) als "x0,y0,x1,y1" für osmium."""
boxes = [b for b, _ in _POLYS[code]]
return ",".join(f"{v:.2f}" for v in (min(b[0] for b in boxes) - margin, min(b[1] for b in boxes) - margin,
max(b[2] for b in boxes) + margin, max(b[3] for b in boxes) + margin))
def in_switzerland(lon: float, lat: float) -> bool:
return _country(lon, lat) == "ch"
def shows(source: str, enabled, lon: float, lat: float) -> bool:
"""Zeigt diese Quelle den Zug an dieser Stelle? Im eigenen Land ja, in einem Land mit eigenem
aktivem Fahrplan nein (dort zeigt ihn dieser). Überall sonst ja."""
c = country(lon, lat)
return c is None or c == source or c not in enabled
File diff suppressed because one or more lines are too long
+112 -7
View File
@@ -2,15 +2,120 @@ import os
from pathlib import Path
DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse"))
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne, ein Ordner pro feed_version
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne: feeds/<quelle>/<version>/
TZ = "Europe/Zurich"
# Schweiz: offizieller GTFS-Fahrplan + GTFS-RT (Trip Updates) von opentransportdata.swiss
GTFS_URL = os.environ.get("TRAINPULSE_GTFS_URL", "") # leer = automatisch das aktuelle Fahrplanjahr
RT_URL = os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt")
RT_KEY = os.environ.get("TRAINPULSE_RT_KEY", "")
RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window)
# Datenquellen. Jede hat einen GTFS-Fahrplan und einen passenden GTFS-RT-Feed (Trip Updates).
SOURCES = {
"ch": { # opentransportdata.swiss; GTFS-URL leer = automatisch das aktuelle Fahrplanjahr
"gtfs_url": os.environ.get("TRAINPULSE_GTFS_URL", ""),
"rt_url": os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt"),
"rt_key": os.environ.get("TRAINPULSE_RT_KEY", ""),
"needs_key": True,
"rt_interval": 35, # Limit sind 2 Abfragen/Minute (Sliding Window)
},
"de": { # gtfs.de (Daten von DELFI e.V.), Fahrplan CC BY 4.0, Echtzeit CC BY-SA 4.0
"gtfs_url": os.environ.get("TRAINPULSE_DE_GTFS_URL", "https://download.gtfs.de/germany/free/latest.zip"),
"rt_url": os.environ.get("TRAINPULSE_DE_RT_URL", "https://realtime.gtfs.de/realtime-free.pb"),
"rt_key": "",
"needs_key": False,
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
},
"fr": { # SNCF (TGV, Intercités, TER) über transport.data.gouv.fr, ODbL
"gtfs_url": "https://eu.ftp.opendatasoft.com/sncf/plandata/Export_OpenData_SNCF_GTFS_NewTripId.zip",
"rt_url": "https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates",
"rt_key": "",
"needs_key": False,
"rt_interval": 60,
},
"nl": { # OVapi / Stichting OpenGeo; Züge und Trams/Metro stehen in zwei getrennten Feeds
"gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip",
# mehrere Echtzeit-Adressen durch Leerzeichen getrennt
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb https://gtfs.ovapi.nl/nl/tripUpdates.pb",
"rt_key": "",
"needs_key": False,
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
},
"be": { # SNCB/NMBS offizielle Open-Data-Feeds (HAFAS); Fahrplan täglich, Echtzeit passt zu dessen trip_ids
"gtfs_url": "https://sncb-opendata.hafas.de/gtfs/static/c21ac6758dd25af84cca5b707f3cb3de",
"rt_url": os.environ.get("TRAINPULSE_BE_RT_URL",
"https://sncb-opendata.hafas.de/gtfs/realtime/d22ad6759ee25bg84ddb6c818g4dc4de_TC"),
"rt_key": os.environ.get("TRAINPULSE_BE_RT_KEY", ""),
"needs_key": False,
"rt_interval": 60,
},
# Spanien: Renfe hat Fern-/Regionalverkehr und Cercanías (S-Bahn) als getrennte Fahrpläne
"es": { # Renfe AVE, Alvia, Media Distancia …; Echtzeit ohne Datum, Trip-IDs enthalten den Tag
"gtfs_url": "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates_LD.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"es_cer": { # Renfe Cercanías/Rodalies (alles S-Bahn)
"country": "es", "all_sbahn": True,
"gtfs_url": "https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"pl": { # alle Bahnbetreiber, aufbereitet von Mikołaj Kuranowski (mkuran.pl); Echtzeit CC BY 4.0
"gtfs_url": "https://mkuran.pl/gtfs/polish_trains.zip",
"rt_url": "https://mkuran.pl/gtfs/polish_trains/updates.pb",
"rt_key": "", "needs_key": False,
"rt_interval": 120, # ~5 MB pro Abruf
},
"no": { # Entur (NLOD); Gesamt-Echtzeit wäre 15 MB, darum nur die Betreiber von Bahn, Tram, T-bane
"gtfs_url": "https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_norway-aggregated-gtfs.zip",
"rt_url": " ".join(f"https://api.entur.io/realtime/v1/gtfs-rt/trip-updates?datasource={ds}" for ds in ("VYG", "FLT", "GOA", "RUT", "ATB", "SKY")),
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"dk": { # Rejseplanen (alle Verkehrsmittel); keine offene Echtzeit
"gtfs_url": "https://www.rejseplanen.info/labs/GTFS.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at": { # ÖBB (CC BY 4.0), einmal pro Fahrplanjahr ({year}); keine offene Echtzeit
"gtfs_url": "https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_{year}.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at_wl": { # Wiener Linien (Tram, U-Bahn, Badner Bahn), CC BY 4.0
"country": "at",
"check_hours": 24, # Server nennt kein Datum: 1x täglich laden (~100 MB) und Inhalt vergleichen
"gtfs_url": "http://www.wienerlinien.at/ogd_realtime/doku/ogd/gtfs/gtfs.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"se": { # Trafiklab GTFS Sweden 3 (alle Züge); kostenloser Key nötig, auch für den Fahrplan
"gtfs_url": "https://opendata.samtrafiken.se/gtfs-sweden/sweden.zip?key={key}",
# Echtzeit nur für einige Regionen (Stockholm, Skåne); Züge landesweit noch nicht
"rt_url": " ".join("https://opendata.samtrafiken.se/gtfs-rt-sweden/{op}/TripUpdatesSweden.pb?key={key}".replace("{op}", op) for op in ("sl", "skane")),
"rt_key": os.environ.get("TRAINPULSE_SE_KEY", ""),
"needs_key": True, "static_needs_key": True,
# Bronze-Key: 30 000 Echtzeit- und 50 Fahrplan-Abrufe im Monat
"rt_interval": int(os.environ.get("TRAINPULSE_SE_RT_INTERVAL") or 300),
"check_hours": 48,
},
}
ENABLED = [s for s in (os.environ.get("TRAINPULSE_SOURCES") or ",".join(SOURCES)).split(",") if s in SOURCES]
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
def country(source: str) -> str:
"""Land einer Quelle (Spanien und Österreich haben je zwei)."""
return SOURCES[source].get("country", source)
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
"https://download.geofabrik.de/europe/france-latest.osm.pbf",
"https://download.geofabrik.de/europe/netherlands-latest.osm.pbf",
"https://download.geofabrik.de/europe/belgium-latest.osm.pbf",
"https://download.geofabrik.de/europe/austria-latest.osm.pbf",
"https://download.geofabrik.de/europe/denmark-latest.osm.pbf",
"https://download.geofabrik.de/europe/spain-latest.osm.pbf",
"https://download.geofabrik.de/europe/norway-latest.osm.pbf",
"https://download.geofabrik.de/europe/poland-latest.osm.pbf",
"https://download.geofabrik.de/europe/sweden-latest.osm.pbf",
])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "-9.6,35.8,31.3,71.3" # von Galicien bis Nordkap, Andalusien bis Finnmark
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+232 -39
View File
@@ -5,6 +5,7 @@ import datetime as dt
import json
import math
import mmap
import re
from array import array
from bisect import bisect_right
from pathlib import Path
@@ -38,16 +39,128 @@ def xy(lon, lat, lat0): # lokale Projektion in Metern, reicht für Abstände en
return lon * 111320 * math.cos(math.radians(lat0)), lat * 110540
BRAKE_M = 150 # so viele Meter vor/nach einem Halt bremst bzw. beschleunigt der Zug
def travelled(length: float, duration: float, t: float) -> float:
"""Zurückgelegte Meter auf einem Abschnitt: sanft anfahren, konstant fahren, sanft bremsen
(trapezförmiges Geschwindigkeitsprofil), statt mit voller Geschwindigkeit in den Halt zu fahren."""
if length <= 0 or duration <= 0:
return 0.0
t = max(0.0, min(t, duration))
ta = min(duration / 2, 2 * BRAKE_M * duration / (length + 2 * BRAKE_M)) # Zeit fürs Anfahren/Bremsen
vmax = length / (duration - ta)
a = vmax / ta
if t < ta:
return 0.5 * a * t * t
if t > duration - ta:
return length - 0.5 * a * (duration - t) ** 2
return 0.5 * a * ta * ta + vmax * (t - ta)
# Gattungen des Fernverkehrs; alles andere (RE, RB, S, R, TER, …) gilt als Nahverkehr
LONG_DISTANCE = {"ICE", "IC", "ICN", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA",
"THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D",
"AVE", "AVLO", "ALVIA", "EUROMED", # Spanien
"EIC", "EIP", "TLK", # Polen
"ICL", "WB", "SJ", "SNT", "X"} # Dänemark (IC Lyn), Westbahn, Schweden (SJ, Snälltåget, X2000)
# Zugnummer mit Gattung ("RJ 658"): bei ÖBB steht der Fernverkehr nur so, die Linie heisst "A10-1"
TRIP_CAT = re.compile(r"^([A-Z]{1,5}) ?(\d+)$")
NO_FJERNTOG = re.compile(r"^F\d+$") # Norwegen: F4, F5, F6 = Fernzüge (aber dänische S-Bahn "F" nicht)
# Frankreich (SNCF): Gattung steht nur in der Trip-ID ("…_F:OUI:…"), die Linie heisst oft "INCONNU"
FR_CATEGORY = {"OUI": "TGV", "OGO": "OUIGO", "LYR": "Lyria", "ICE": "ICE", "IC": "IC", "ICN": "IC",
"TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"}
FR_TRIP = re.compile(r"_F:([A-Z]+):")
# Niederlande (OVapi): lange Gattungsnamen kürzen, damit die Beschriftung auf der Karte passt
LINE_ALIAS = {"Intercity": "IC", "Intercity direct": "ICD", "Sprinter": "SPR", "Nightjet": "NJ", "Eurocity": "EC"}
LINE_PREFIX = re.compile(r"^(Stoptrein|Sneltrein) (?=\S)") # "Stoptrein RS4" -> "RS4"
# Steige im deutschen Fahrplan: "S+U Rathaus Spandau Bstggl.2", "Zingster Str., Ausstieg Gl.3-5"
STOP_SUFFIX = re.compile(r",? (Ausstieg\b.*|Bstg\S*|Gl\.\s?\S+)$")
def route_mode(route_type: str) -> str | None:
"""rail, tram, subway – oder None (Bus, Schiff, Seilbahn …). GTFS-Grundtypen und "extended route types"."""
t = int(route_type)
if t == 2 or 100 <= t <= 199:
return "rail"
if t == 0 or 900 <= t <= 906:
return "tram"
if t == 1 or 400 <= t <= 404:
return "subway"
return None
SBAHN = re.compile(r"^(SN? ?\d+[A-Z]?|S)$") # S1, S 5, S41, SN7 (Nacht-S-Bahn), nur "S"
def sbahn(cat: str, line: str, route_type: str = "") -> bool:
"""S-Bahn: Schweiz über die Kategorie, Deutschland/Belgien über den Liniennamen, Dänemark (S-tog)
über den Routentyp 109. Niederländische Sprinter und französische TER zählen als Regionalzug."""
return cat in ("S", "SN") or route_type == "109" or bool(SBAHN.match(line))
def long_distance(cat: str, line: str) -> bool:
# Schweiz: Gattung in route_desc; gtfs.de: nur in der Linie ("ICE 75", "IC 3")
m = re.match(r"[A-Za-z]+", cat or line or "")
return bool(m) and m.group(0).upper() in LONG_DISTANCE or bool(NO_FJERNTOG.match(line or ""))
MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten
def with_min_dwell(arr, dep):
"""Halte mit weniger als MIN_DWELL Haltezeit für die Animation verlängern: etwas früher ankommen,
etwas später abfahren – höchstens ein Viertel der Fahrzeit davor/danach."""
pa, pd = list(arr), list(dep)
for k in range(1, len(arr) - 1):
short = MIN_DWELL - (dep[k] - arr[k])
if short > 0:
h = min(short / 2, (arr[k] - dep[k - 1]) / 4, (arr[k + 1] - dep[k]) / 4)
if h > 0:
pa[k], pd[k] = arr[k] - h, dep[k] + h
return pa, pd
def simplify(p: list, tol_m: float, fixed=()) -> list:
"""Douglas-Peucker auf [lon, lat, t]-Punkten (Toleranz in Metern, grob über die Breite umgerechnet).
Punkte in `fixed` bleiben immer erhalten."""
if len(p) < 3:
return p
kx = 111320 * math.cos(math.radians(p[0][1]))
keep = {0, len(p) - 1, *fixed}
cuts = sorted(keep)
stack = list(zip(cuts, cuts[1:]))
while stack:
a, b = stack.pop()
(x1, y1), (x2, y2) = (p[a][0] * kx, p[a][1] * 110540), (p[b][0] * kx, p[b][1] * 110540)
dx, dy = x2 - x1, y2 - y1
n = math.hypot(dx, dy)
best, idx = 0.0, -1
for i in range(a + 1, b):
x, y = p[i][0] * kx, p[i][1] * 110540
d = abs(dy * x - dx * y + x2 * y1 - y2 * x1) / n if n else math.hypot(x - x1, y - y1)
if d > best:
best, idx = d, i
if best > tol_m:
keep.add(idx)
stack += [(a, idx), (idx, b)]
return [p[i] for i in sorted(keep)]
class Feed:
def __init__(self, path: Path, today: dt.date | None = None):
def __init__(self, path: Path, today: dt.date | None = None, all_sbahn: bool = False):
self.path = path
self.meta = json.loads((path / "meta.json").read_text())
self.version = self.meta["feed_version"]
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], []
self.stop_group = array("i") # Bahnhof eines Halts (alle Gleise eines Bahnhofs -> gleiche Zahl)
groups = {}
for h, r in read(path / "stops.txt"):
parent = r[h["parent_station"]] if "parent_station" in h else ""
self.stop_group.append(groups.setdefault(parent or r[h["stop_id"]], len(groups)))
self.stop_index[r[h["stop_id"]]] = len(self.stop_name)
self.stop_name.append(r[h["stop_name"]])
self.stop_name.append(STOP_SUFFIX.sub("", r[h["stop_name"]]))
self.stop_lat.append(float(r[h["stop_lat"]]))
self.stop_lon.append(float(r[h["stop_lon"]]))
self.stop_platform.append(r[h["platform_code"]] if "platform_code" in h else "")
@@ -55,10 +168,14 @@ class Feed:
agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")}
self.routes = {}
for h, r in read(path / "routes.txt"):
line = LINE_PREFIX.sub("", r[h["route_short_name"]] or r[h.get("route_long_name", 0)])
if len(line) > 12: # Entur: "Ø Göteborg-Halmstad-…" -> "Ø"; Kartenbeschriftung bleibt kurz
line = line.split()[0][:12]
self.routes[r[h["route_id"]]] = {
"line": r[h["route_short_name"]] or r[h.get("route_long_name", 0)],
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line),
"cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
"mode": route_mode(r[h["route_type"]]) or "rail", "type": r[h["route_type"]],
}
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
@@ -71,6 +188,23 @@ class Feed:
self.trip_headsign.append(r[h["trip_headsign"]] if "trip_headsign" in h else "")
self.trip_number.append(r[h["trip_short_name"]] if "trip_short_name" in h else "")
self.trip_shape.append(r[h["shape_id"]] if "shape_id" in h else "")
route = self.routes[r[h["route_id"]]]
m = FR_TRIP.search(r[h["trip_id"]]) if not route["cat"] else None
if m:
route["cat"] = FR_CATEGORY.get(m.group(1), m.group(1))
if self.trip_headsign[-1].isdigit() and not self.trip_number[-1]: # SNCF: Ziel = Zugnummer
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
for route in self.routes.values():
route["line"] = route["line"] or route["cat"]
# Routentyp 101/102/105 (Hochgeschwindigkeit, Fern-, Nachtzug): Schweden kennt sonst nur Nummern
route["fv"] = route["mode"] == "rail" and (long_distance(route["cat"], route["line"])
or route["type"] in ("101", "102", "105"))
route["sb"] = route["mode"] == "rail" and not route["fv"] and (all_sbahn or sbahn(route["cat"], route["line"], route["type"]))
self.trip_cat = {} # Fahrt -> Gattung aus der Zugnummer, wo die Linie keine Fernverkehrs-Gattung hat
for ti, num in enumerate(self.trip_number):
m = TRIP_CAT.match(num) if num and not num.isdigit() else None
if m and m.group(1) in LONG_DISTANCE and not self.routes[self.trip_route[ti]]["fv"]:
self.trip_cat[ti], self.trip_number[ti] = m.group(1), m.group(2)
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route)
@@ -92,6 +226,9 @@ class Feed:
self.st_dep.append(d)
self.st_dist.append(m)
self.off[n] = len(self.st_stop)
for ti in range(n): # gtfs.de liefert kein Fahrtziel: dann der letzte Halt
if not self.trip_headsign[ti] and self.off[ti + 1] > self.off[ti]:
self.trip_headsign[ti] = self.stop_name[self.st_stop[self.off[ti + 1] - 1]]
self.shape_index, self.shape_data = {}, None
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
@@ -100,7 +237,7 @@ class Feed:
self.shape_data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)).cast("f")
self.calendar = {}
for h, r in read(path / "calendar.txt"):
for h, r in read(path / "calendar.txt") if (path / "calendar.txt").exists() else (): # SNCF: nur Ausnahmen
self.calendar[r[h["service_id"]]] = (
[r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")],
r[h["start_date"]], r[h["end_date"]])
@@ -112,12 +249,12 @@ class Feed:
cd = path / "calendar_dates.txt"
if cd.exists():
with open(cd, encoding="utf-8-sig") as f:
assert next(f).strip().replace('"', "").startswith("service_id,date,exception_type")
h = next(f).strip().replace('"', "").split(",") # Spaltenreihenfolge ist je Quelle anders
si, di, ti_ = h.index("service_id"), h.index("date"), h.index("exception_type")
for line in f:
sid, date, typ = line.rstrip("\r\n").rsplit(",", 2)
date = date.strip('"')
if date in window:
self.exceptions.setdefault(date, {})[sid.strip('"')] = int(typ.strip('"'))
v = line.rstrip("\r\n").replace('"', "").split(",") # kurze Datei ohne Kommas in Werten
if v[di] in window:
self.exceptions.setdefault(v[di], {})[v[si]] = int(v[ti_])
self._day_cache = {}
self._geom_cache = {}
@@ -172,6 +309,23 @@ class Feed:
self._geom_cache[key] = g
return g
def track(self, ti: int, dists: list, step: float, tolerance: float = 3.0) -> list:
"""Gleispunkte von der aktuellen Position bis zur Position in len(dists)-1 Schritten, je mit der
Zeit (s ab jetzt), zu der der Zug dort ist. Gerade Stücke werden auf ~3 m vereinfacht."""
pts, cum, _ = self.geometry(ti)
out, fixed = [], []
for k in range(len(dists)):
d0 = dists[k]
fixed.append(len(out)) # Zeitpunkte alle `step` s behalten, sonst ginge das Bremsen verloren
out.append((d0, k * step))
if k + 1 < len(dists) and dists[k + 1] > d0:
d1 = dists[k + 1]
for j in range(bisect_right(cum, d0), bisect_right(cum, d1)):
if d0 < cum[j] < d1:
out.append((cum[j], (k + (cum[j] - d0) / (d1 - d0)) * step))
res = [[*self.point_at((pts, cum, None), d), round(t, 2), round(d)] for d, t in out] # d: m ab Fahrtbeginn
return simplify(res, tolerance, fixed)
@staticmethod
def point_at(g, d: float):
pts, cum, _ = g
@@ -185,38 +339,54 @@ class Feed:
def times(self, ti: int, ds: float, upd):
"""Ankunft/Abfahrt je Halt (Sekunden ab Betriebstag), mit Verspätungen aus GTFS-RT.
Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird."""
Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird.
Dazu die Nummern (0 …) der ausfallenden Halte."""
o0, o1 = self.off[ti], self.off[ti + 1]
arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
if not upd:
return arr, dep
seqs = self.st_seq[o0:o1]
by_i = {}
return arr, dep, set()
seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1]
by_i, j = {}, 0
for u in upd:
i = bisect_right(seqs, u[0]) - 1
if i >= 0 and seqs[i] == u[0]:
by_i[i] = u
if u[0] is not None: # Schweiz, Deutschland: über die Halt-Nummer
i = bisect_right(seqs, u[0]) - 1
if i >= 0 and seqs[i] == u[0]:
by_i[i] = u
continue
# Frankreich, Niederlande: nur Haltestellen-ID; über den Bahnhof abgleichen, damit es
# auch bei Gleiswechsel passt, und der Reihe nach (eine Fahrt kann einen Bahnhof zweimal haben)
k = self.stop_index.get(u[1])
if k is None: # Belgien meldet das Gleis ("8863008_10"), der Fahrplan nur den Bahnhof ("8863008")
k = self.stop_index.get(u[1].rsplit("_", 1)[0])
if k is None:
continue
for i in range(j, len(stops)):
if self.stop_group[stops[i]] == self.stop_group[k]:
by_i[i], j = u, i + 1
break
cur, prev = None, -10 ** 9
for i in range(len(arr)):
u = by_i.get(i)
if u and u[1] is not None:
a = u[1] - ds if u[2] else arr[i] + u[1]
if u and u[2] is not None:
a = u[2] - ds if u[3] else arr[i] + u[2]
cur = a - arr[i]
else:
a = arr[i] + (cur or 0)
if u and u[3] is not None:
d = u[3] - ds if u[4] else dep[i] + u[3]
if u and u[4] is not None:
d = u[4] - ds if u[5] else dep[i] + u[4]
cur = d - dep[i]
else:
d = dep[i] + (cur or 0)
a = max(a, prev)
d = max(d, a)
arr[i], dep[i], prev = a, d, d
return arr, dep
return arr, dep, {i for i, u in by_i.items() if u[6]}
# ---------- Positionen ----------
def vehicles(self, now: float, rt: dict):
def vehicles(self, now: float, rt: dict, ahead: tuple = ()):
"""Fahrzeuge zur Zeit now. Mit ahead (Sekunden) zusätzlich "_ahead": Strecken-Meter zu now+s,
solange der Zug fährt – ohne den Fahrplan dafür jedes Mal neu durchzurechnen."""
feats = []
today = dt.datetime.fromtimestamp(now, TZ).date()
for d in (today - dt.timedelta(days=1), today):
@@ -227,57 +397,80 @@ class Feed:
break # Liste ist nach Abfahrt sortiert
if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden
continue
r = rt.get((self.trip_ids[ti], dstr))
r = self.rt_for(rt, ti, dstr)
if r and r[0]:
continue # ausgefallen
arr, dep = self.times(ti, ds, r[1] if r else None)
if not (dep[0] <= t <= arr[-1]):
arr, dep, skip = self.times(ti, ds, r[1] if r else None)
# Teilausfall: fährt nur zwischen erstem und letztem bedienten Halt; Deutschland meldet
# auch ganze Ausfälle so (alle Halte fallen aus)
live = [k for k in range(len(arr)) if k not in skip]
if not live or not (dep[live[0]] <= t <= arr[live[-1]]):
continue
i = min(bisect_right(dep, t), len(dep) - 1) # Zug ist zwischen Halt i-1 und i (oder steht an Halt i)
pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt
g = self.geometry(ti)
sd = g[2]
o = self.off[ti]
if t >= arr[i]: # steht am Halt i
dist, delay = sd[i], dep[i] - self.st_dep[o + i]
def at(tt): # -> (Halt i, Strecken-Meter); Zug zwischen Halt i-1 und i oder steht an i
i = min(bisect_right(pd, tt), len(pd) - 1)
if tt >= pa[i]:
return i, sd[i]
return i, sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], tt - pd[i - 1])
i, dist = at(t)
if t >= pa[i]: # steht am Halt i
delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i]
else:
f = (t - dep[i - 1]) / max(1, arr[i] - dep[i - 1])
dist, delay = sd[i - 1] + f * (sd[i] - sd[i - 1]), arr[i] - self.st_arr[o + i]
delay = arr[i] - self.st_arr[o + i]
lon, lat = self.point_at(g, dist)
route = self.routes[self.trip_route[ti]]
# Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand
(x1, y1), (x2, y2) = self.point_at(g, dist - 25), self.point_at(g, dist + 25)
dx, dy = (x2 - x1) * math.cos(math.radians(lat)), y2 - y1
heading = round(math.degrees(math.atan2(dx, dy)), 1) if dx or dy else None
route, tc = self.routes[self.trip_route[ti]], self.trip_cat.get(ti)
feats.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"id": f"{dstr}/{ti}", "line": tc or route["line"], "cat": route["cat"], "mode": route["mode"],
"fv": bool(tc) or route["fv"], "sb": not tc and route["sb"],
"nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti],
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading,
},
"_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt
"_ahead": [at(t + s)[1] for s in ahead if t + s <= arr[live[-1]]],
})
return feats
def rt_for(self, rt: dict, ti: int, date: str):
# Renfe liefert kein Datum; dort ist die Trip-ID je Betriebstag eindeutig
return rt.get((self.trip_ids[ti], date)) or rt.get((self.trip_ids[ti], ""))
def trip_detail(self, date: str, ti: int, now: float, rt: dict):
d = dt.datetime.strptime(date, "%Y%m%d").date()
ds = day_start(d)
r = rt.get((self.trip_ids[ti], date))
arr, dep = self.times(ti, ds, r[1] if r else None)
r = self.rt_for(rt, ti, date)
arr, dep, skip = self.times(ti, ds, r[1] if r else None)
o0, o1 = self.off[ti], self.off[ti + 1]
def hhmm(sec):
return dt.datetime.fromtimestamp(ds + sec, TZ).strftime("%H:%M")
stops = []
stops, g = [], self.geometry(ti)
for k, i in enumerate(range(o0, o1)):
s = self.st_stop[i]
stops.append({
"name": self.stop_name[s], "platform": self.stop_platform[s],
**dict(zip(("lon", "lat"), self.point_at(g, g[2][k]))), # Punkt auf der Strecke
"arr": hhmm(self.st_arr[i]) if k > 0 else None,
"dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None,
"arr_delay": arr[k] - self.st_arr[i] if r else None,
"dep_delay": dep[k] - self.st_dep[i] if r else None,
"passed": now - ds > dep[k],
"passed": now - ds > dep[k], "skipped": k in skip,
})
route = self.routes[self.trip_route[ti]]
return {"line": route["line"], "cat": route["cat"], "agency": route["agency"],
return {"line": self.trip_cat.get(ti, route["line"]), "cat": route["cat"], "agency": route["agency"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"realtime": bool(r), "canceled": bool(r and r[0]),
"realtime": bool(r), "canceled": bool(r and r[0]) or len(skip) == len(stops),
"stops": stops, "shape": self.geometry(ti)[0]}
+236 -73
View File
@@ -1,12 +1,14 @@
"""Fahrplan-Update: holt den Schweizer GTFS-Fahrplan, filtert auf Bahn und rechnet mit pfaedle
die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich per systemd-Timer und
macht nichts, solange opentransportdata.swiss keinen neuen Fahrplan veröffentlicht hat.
"""Fahrplan-Update: holt für jede Quelle (Schweiz, Deutschland) den GTFS-Fahrplan, filtert auf Bahn
und rechnet mit pfaedle die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich
und macht für eine Quelle nichts, solange dort kein neuer Fahrplan veröffentlicht wurde.
python -m trainpulse.prepare # normaler Lauf
python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen)
python -m trainpulse.prepare # alle Quellen
python -m trainpulse.prepare --source de --force # eine Quelle neu aufbereiten
python -m trainpulse.prepare --source ch --zip X # lokale Datei statt Download (zum Testen)
"""
import argparse
import csv
import hashlib
import datetime as dt
import io
import json
@@ -20,14 +22,16 @@ from pathlib import Path
import httpx
from . import border
from . import config as C
from .feed import route_mode
log = logging.getLogger("prepare")
def is_rail(route_type: str) -> bool:
t = int(route_type)
return t == 2 or 100 <= t <= 199 # GTFS + "extended route types" (Schweiz nutzt 100–117)
# Aufbereitungs-Version: ändert sich, was aus dem Fahrplan übernommen wird, rechnet der Updater
# alle Fahrpläne einmal neu, auch ohne neuen Fahrplan (2: mit Trams und U-Bahnen)
FORMAT = 2
def timetable_year(today: dt.date) -> int:
@@ -39,10 +43,14 @@ def timetable_year(today: dt.date) -> int:
return today.year
def gtfs_url() -> str:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
return C.GTFS_URL or (
f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink")
def gtfs_url(src: str) -> str:
cfg = C.SOURCES[src]
# {year}: ÖBB (Datei je Fahrplanjahr), {key}: Schweden (Key in der Adresse)
url = cfg["gtfs_url"].replace("{year}", str(timetable_year(dt.date.today()))).replace("{key}", cfg["rt_key"])
if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
return url
def download(url: str, dest: Path) -> None:
@@ -50,12 +58,31 @@ def download(url: str, dest: Path) -> None:
with httpx.stream("GET", url, follow_redirects=True, timeout=120,
headers={"User-Agent": C.USER_AGENT}) as r:
r.raise_for_status()
total, done, t0, last = int(r.headers.get("content-length", 0)), 0, time.time(), time.time()
with open(tmp, "wb") as f:
for chunk in r.iter_bytes(1 << 20):
f.write(chunk)
done += len(chunk)
if time.time() - last > 30: # Fortschritt, damit ein langsamer Download nicht wie ein Hänger aussieht
last = time.time()
log.info(" … %d von %d MB (%.1f MB/s)", done >> 20, total >> 20, done / (last - t0) / 1e6)
tmp.rename(dest)
def remote_id(src: str, url: str) -> tuple[str, str | None]:
"""Kennung der aktuell veröffentlichten Datei, ohne sie herunterzuladen: (Name, Datum JJJJMMTT oder None).
Name None: der Server sagt nichts (Wiener Linien) – dann entscheidet der Inhalt nach dem Download."""
if src == "ch":
return latest_gtfs_name(url), None
r = httpx.head(url, follow_redirects=True, timeout=60, headers={"User-Agent": C.USER_AGENT})
r.raise_for_status()
lm = r.headers.get("last-modified")
if not lm:
return None, dt.date.today().strftime("%Y%m%d")
date = dt.datetime.strptime(lm, "%a, %d %b %Y %H:%M:%S GMT").strftime("%Y%m%d")
return f"{src}-{lm}", date
def latest_gtfs_name(url: str) -> str:
# Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht,
# darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt.
@@ -83,56 +110,92 @@ def write_csv(path: Path, header, data) -> int:
return n
def filter_by_first_col(z: zipfile.ZipFile, name: str, keys: set, out: Path, collect: str | None = None):
"""Schnellfilter für die Riesendateien (stop_times.txt hat ~3.7 GB): vergleicht nur das erste Feld
als Rohtext, schreibt Treffer unverändert weg. Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path, collect: str | None = None):
"""Filter für die Riesendateien (stop_times.txt hat mehrere GB): behält Zeilen, deren Spalte `col`
in `keys` ist, und schreibt sie unverändert weg. Steht `col` vorne, wird nur das erste Feld als
Rohtext verglichen (schnell). Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
n, seen = 0, set()
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
header = next(csv.reader([lines.readline()]))
assert header[0] in ("trip_id", "service_id"), header
f.write(",".join(header) + "\n")
ci = header.index(collect) if collect else None
ki, ci = header.index(col), header.index(collect) if collect else None
for line in lines:
if line.split(",", 1)[0].strip('"') in keys:
f.write(line)
n += 1
if ci is not None:
seen.add(next(csv.reader([line]))[ci])
if ki == 0:
if line.split(",", 1)[0].strip('"') not in keys:
continue
row = next(csv.reader([line])) if ci is not None else None
else:
row = next(csv.reader([line]))
if row[ki] not in keys:
continue
f.write(line)
n += 1
if ci is not None:
seen.add(row[ci])
return n, seen
def filter_rail(zip_path: Path, out: Path) -> dict:
def normalize_zip(zip_path: Path, work: Path) -> Path:
"""Eigenheiten mancher Fahrpläne glätten: Dateien in einem Unterordner (ÖBB) und mit Leerzeichen
aufgefüllte Felder ("10T0001C1 ", Renfe Cercanías). Sonst die Datei unverändert."""
z = zipfile.ZipFile(zip_path)
members = {Path(n).name: n for n in z.namelist() if n.endswith(".txt")}
padded = any(c != c.strip() for n in members.values() for c in rows(z, n)[0])
if not padded and all(members[k] == k for k in members):
return zip_path
out = work / "normalized.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as o:
for base, name in members.items():
with z.open(name) as src, o.open(base, "w") as dst:
if not padded:
shutil.copyfileobj(src, dst, 1 << 20)
continue
with io.TextIOWrapper(dst, encoding="utf-8", newline="") as t: # schliessen = alles schreiben
w = csv.writer(t)
for row in csv.reader(io.TextIOWrapper(src, encoding="utf-8-sig", newline="")):
w.writerow([c.strip() for c in row])
return out
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
h, rd = rows(z, "routes.txt")
routes = [r for r in rd if is_rail(r[h.index("route_type")])]
routes = [r for r in rd if route_mode(r[h.index("route_type")])]
write_csv(out / "routes.txt", h, routes)
route_ids = {r[h.index("route_id")] for r in routes}
h, rd = rows(z, "trips.txt")
trips = [r for r in rd if r[h.index("route_id")] in route_ids]
if "shape_id" in h: # shapes.txt kopieren wir nicht (pfaedle rechnet neu) -> Verweise leeren (z. B. OVapi)
for r in trips:
r[h.index("shape_id")] = ""
write_csv(out / "trips.txt", h, trips)
trip_ids = {r[h.index("trip_id")] for r in trips}
service_ids = {r[h.index("service_id")] for r in trips}
if "frequencies.txt" in z.namelist():
fh, frd = rows(z, "frequencies.txt")
n = sum(1 for r in frd if r[0] in trip_ids)
n = sum(1 for r in frd if r[fh.index("trip_id")] in trip_ids)
if n:
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n)
n_st, used_stops = filter_by_first_col(z, "stop_times.txt", trip_ids, out / "stop_times.txt", "stop_id")
n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id")
h, rd = rows(z, "stops.txt")
sid = h.index("stop_id")
stops = list(rd)
parents = {r[h.index("parent_station")] for r in stops if r[0] in used_stops}
write_csv(out / "stops.txt", h, (r for r in stops if r[0] in used_stops or r[0] in parents))
parents = {r[h.index("parent_station")] for r in stops if r[sid] in used_stops} if "parent_station" in h else set()
if "level_id" in h: # levels.txt kopieren wir nicht (ÖBB) -> Verweise leeren, sonst bricht pfaedle ab
for r in stops:
r[h.index("level_id")] = ""
write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"):
if name in z.namelist():
filter_by_first_col(z, name, service_ids, out / name)
filter_by_col(z, name, "service_id", service_ids, out / name)
for name in ("agency.txt", "feed_info.txt"):
if name in z.namelist():
h, rd = rows(z, name)
@@ -140,32 +203,66 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
info = dict(zip(h, next(rd, [])))
return {"feed_version": info.get("feed_version") or zip_path.stem,
fv = info.get("feed_version", "")
return {"feed_version": fv if fv.isdigit() else (version or zip_path.stem), # gtfs.de: "latest-free"
"routes": len(routes), "trips": len(trips), "stop_times": n_st}
def ensure_osm() -> Path:
osm = C.DATA_DIR / "osm" / "region.osm.pbf"
if not osm.exists() or time.time() - osm.stat().st_mtime > 30 * 86400:
osm.parent.mkdir(parents=True, exist_ok=True)
log.info("Lade OSM-Daten %s", C.OSM_URL)
download(C.OSM_URL, osm)
return osm
"""Schienennetz aus OpenStreetMap für alle Länder: herunterladen, mit osmium auf Bahn-Objekte
filtern (Deutschland: ~4 GB -> wenige 100 MB) und zusammenführen. Monatlich erneuert."""
d = C.DATA_DIR / "osm"
rail, urls_file = d / "rail.osm.pbf", d / "urls.txt"
wanted = "\n".join(C.OSM_URLS)
if rail.exists() and time.time() - rail.stat().st_mtime < 30 * 86400 \
and urls_file.exists() and urls_file.read_text() == wanted:
return rail
d.mkdir(parents=True, exist_ok=True)
parts = []
for i, url in enumerate(C.OSM_URLS):
raw, part = d / f"download-{i}.osm.pbf", d / f"rail-{i}.osm.pbf"
log.info("Lade OSM-Daten %s", url)
download(url, raw)
log.info("Filtere Schienennetz …")
subprocess.run([C.OSMIUM, "tags-filter", str(raw), "nwr/railway", "nwr/public_transport", "r/route",
"-o", str(part), "--overwrite"], check=True)
raw.unlink() # spart Platz
parts.append(str(part))
merged, tmp = d / "rail.merged.osm.pbf", d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(merged), "--overwrite"], check=True)
# Die Länder-Dateien sind unterschiedlich alt: Objekte an den Grenzen stecken dann in zwei
# Versionen drin, woran osmium extract scheitert. time-filter behält je Objekt die neueste.
subprocess.run([C.OSMIUM, "time-filter", str(merged), "-o", str(tmp), "--overwrite"], check=True)
# IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM für die weit verstreuten OSM-IDs
subprocess.run([C.OSMIUM, "renumber", str(tmp), "-o", str(merged), "--overwrite"], check=True)
tmp.unlink()
merged.rename(rail)
urls_file.write_text(wanted)
for p in parts:
Path(p).unlink()
for old in d.glob("region.osm.pbf"): # Datei der alten Version (nur Schweiz)
old.unlink()
return rail
def run_pfaedle(work: Path, rail: Path) -> Path | None:
def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
if not shutil.which(C.PFAEDLE):
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
return None
osm = ensure_osm()
log.info("pfaedle rechnet Streckenverläufe …")
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(C.country(source)), str(ensure_osm()), "-o", str(osm),
"--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time()
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work)
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail,tram,subway", "-x", str(osm), str(rail)], cwd=work)
osm.unlink()
out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists():
log.error("pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", res.returncode)
log.error("[%s] pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", source, res.returncode)
return None
log.info("pfaedle fertig in %.0f s", time.time() - t)
log.info("[%s] pfaedle fertig in %.0f s", source, time.time() - t)
return out
@@ -201,60 +298,126 @@ def pack_shapes(d: Path) -> None:
src.unlink()
def publish(src: Path, meta: dict) -> None:
def ensure_tiles() -> None:
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen
(erneut, wenn sich der Ausschnitt geändert hat)."""
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
info = C.DATA_DIR / "tiles" / "region.txt"
wanted = f"{C.TILES_BBOX} z{C.TILES_MAXZOOM}"
if dest.exists() and dest.stat().st_size and info.exists() and info.read_text() == wanted:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp")
for days in range(1, 8): # Protomaps baut täglich, ältere Builds werden gelöscht
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", f"--maxzoom={C.TILES_MAXZOOM}"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest)
info.write_text(wanted)
return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(source: str, src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta))
dest = C.FEEDS_DIR / meta["feed_version"]
base = C.FEEDS_DIR / source
base.mkdir(parents=True, exist_ok=True)
dest = base / meta["feed_version"]
if dest.exists():
shutil.rmtree(dest)
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner
for old in sorted(p for p in C.FEEDS_DIR.iterdir() if p.is_dir())[:-3]:
for old in sorted(p for p in base.iterdir() if p.is_dir())[:-2]:
shutil.rmtree(old)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool = False) -> None:
work = C.DATA_DIR / "work"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
if args.zip:
zip_path, name = args.zip, args.zip.name
cfg, version = C.SOURCES[source], None
if zip_path:
name = zip_path.name
else:
url = gtfs_url()
name = latest_gtfs_name(url)
if name == state.get("last_file") and not args.force:
log.info("Kein neuer Fahrplan (%s)", name)
if cfg.get("static_needs_key") and not cfg["rt_key"]:
log.info("[%s] Kein Key eingetragen – übersprungen", source)
return
zip_path = work / name
log.info("Lade %s", name)
if time.time() - state.get(source + "@checked", 0) < cfg.get("check_hours", 0) * 3600 and not force:
return # Anbieter mit knappem Abruf-Kontingent (Schweden) nicht stündlich fragen
state[source + "@checked"] = time.time()
url = gtfs_url(source)
name, version = remote_id(source, url)
if name and f"{name} #{FORMAT}" == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return
zip_path = work / "gtfs.zip"
log.info("[%s] Lade Fahrplan %s", source, name or "(ohne Datum vom Server)")
download(url, zip_path)
if not name: # ohne Datum vom Server: am Inhalt erkennen, ob es Neues gibt
with open(zip_path, "rb") as f:
name = f"{source}-{hashlib.file_digest(f, 'sha256').hexdigest()[:16]}"
if f"{name} #{FORMAT}" == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (gleicher Inhalt)", source)
return
t = time.time()
meta = filter_rail(zip_path, work / "rail")
log.info("Bahn-Filter: %s in %.0f s", meta, time.time() - t)
if not args.zip:
zip_path.unlink()
version = version or zip_path.stem # (nach dem Glätten hiesse die Datei sonst "normalized")
zip_path = normalize_zip(zip_path, work)
meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
for p in (work / "gtfs.zip", work / "normalized.zip"):
p.unlink(missing_ok=True)
shaped = run_pfaedle(work, work / "rail")
shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None
if shaped:
pack_shapes(shaped)
meta["source_file"] = name
meta["format"] = FORMAT
meta["prepared_at"] = int(time.time())
publish(shaped or work / "rail", meta)
state["last_file"] = name
state_file.write_text(json.dumps(state))
publish(source, shaped or work / "rail", meta)
shutil.rmtree(work, ignore_errors=True)
log.info("Fahrplan %s bereit", meta["feed_version"])
state[source] = f"{name} #{FORMAT}"
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--source", choices=list(C.SOURCES), help="nur diese Quelle (Standard: alle aktiven)")
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download (nur mit --source)")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # würde Adressen samt Key (Schweden) loggen
if args.zip and not args.source:
ap.error("--zip braucht --source")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
for p in C.FEEDS_DIR.iterdir():
if not p.is_dir() or p.name in C.SOURCES:
continue
if p.name.isdigit() and (p / "meta.json").exists(): # alte Version: feeds/<version> war die Schweiz
(C.FEEDS_DIR / "ch").mkdir(exist_ok=True)
p.rename(C.FEEDS_DIR / "ch" / p.name)
else:
shutil.rmtree(p)
if not args.zip:
ensure_tiles()
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
failed = False
for source in [args.source] if args.source else C.ENABLED:
try:
prepare(source, state, args.zip, args.force)
except Exception: # eine kaputte Quelle soll die anderen nicht aufhalten
log.exception("[%s] Aufbereitung fehlgeschlagen", source)
failed = True
state_file.write_text(json.dumps(state))
if failed:
raise SystemExit(1)
if __name__ == "__main__":
+14 -6
View File
@@ -5,10 +5,12 @@ from google.transit import gtfs_realtime_pb2 as pb
from . import config as C
CANCELED = pb.TripDescriptor.CANCELED
SKIPPED = pb.TripUpdate.StopTimeUpdate.SKIPPED
def parse(data: bytes, known_trips: dict):
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, ank, ank_absolut, abf, abf_absolut), ...])})
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, stop_id, ank, ank_absolut, abf, abf_absolut, fällt_aus), ...])})
seq ist None, wenn die Quelle nur die Haltestellen-ID liefert (Frankreich, Niederlande).
ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
feed = pb.FeedMessage()
feed.ParseFromString(data)
@@ -25,17 +27,23 @@ def parse(data: bytes, known_trips: dict):
for name in ("arrival", "departure"):
if s.HasField(name):
x = getattr(s, name)
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
# Verspätung vor Uhrzeit: Renfe liefert beides, und die Uhrzeit ist dort oft falsch
ev += [x.delay, False] if x.HasField("delay") else [x.time, True] if x.HasField("time") else [None, False]
else:
ev += [None, False]
upd.append((s.stop_sequence, *ev))
upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev,
s.schedule_relationship == SKIPPED))
out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd)
version = feed.header.feed_version if feed.header.HasField("feed_version") else None
return version, out
def fetch() -> bytes:
r = httpx.get(C.RT_URL, follow_redirects=True, timeout=30, headers={
"Authorization": f"Bearer {C.RT_KEY}", "User-Agent": C.USER_AGENT})
def fetch(url: str, key: str = "") -> bytes:
headers = {"User-Agent": C.USER_AGENT, "ET-Client-Name": "trainpulse"} # Entur möchte eine Kennung
if "{key}" in url: # Schweden: Key in der Adresse
url = url.replace("{key}", key)
elif key:
headers["Authorization"] = f"Bearer {key}"
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
r.raise_for_status()
return r.content
+170 -52
View File
@@ -15,91 +15,170 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from . import config as C
from . import realtime
from .feed import TZ, Feed
from . import border, realtime
from .feed import TZ, Feed, read, route_mode
log = logging.getLogger("trainpulse")
AHEAD = 10
STEP, STEPS = 2, 6 # Gleisverlauf für die nächsten 12 s (Abruf alle 5 s, Berechnung alle 3 s -> Reserve)
WEB = Path(__file__).resolve().parent.parent / "web"
class Source:
"""Ein Fahrplan (Schweiz, Deutschland, …) mit seinem Echtzeit-Feed."""
def __init__(self, name: str):
self.name, self.cfg = name, C.SOURCES[name]
self.feed: Feed | None = None
self.rt: dict = {}
self.rt_version: str | None = None
self.rt_at: float | None = None
self.rt_error: str | None = None
self.rt_tried_at: float | None = None
self.on_disk = False # ein aufbereiteter Fahrplan liegt bereit (wird gleich geladen)
self.loaded_at = 0.0
@property
def has_rt(self) -> bool:
return bool(self.cfg["rt_url"]) and (bool(self.cfg["rt_key"]) or not self.cfg["needs_key"])
class State:
feed: Feed | None = None
rt: dict = {}
rt_version: str | None = None
rt_at: float | None = None
rt_error: str | None = None
sources = {name: Source(name) for name in C.ENABLED}
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
vehicles_gz: bytes = gzip.compress(vehicles)
count = 0
# Trams/U-Bahnen: zu viele für eine Gesamtliste -> je Fahrzeug fertiges JSON, ausgeliefert nur
# für den Kartenausschnitt
urban: tuple = (0.0, []) # (Berechnungszeit, [(lon, lat, json-bytes)]) – ein Objekt, damit beides zusammenpasst
computed_at: float | None = None
S = State()
def loop(every: float, fn):
def loop(every: float, fn, *args):
def run():
while True:
t = time.time()
try:
fn()
fn(*args)
except Exception:
log.exception("Fehler in %s", fn.__name__)
log.exception("Fehler in %s %s", fn.__name__, args)
time.sleep(max(1.0, every - (time.time() - t)))
threading.Thread(target=run, daemon=True, name=fn.__name__).start()
def feed_format(path: Path, meta: dict) -> int:
"""Aufbereitungs-Format; ältere Ordner ohne Angabe: 2, wenn Trams/U-Bahnen drin sind."""
if "format" in meta:
return meta["format"]
return 2 if any(route_mode(r[h["route_type"]]) != "rail" for h, r in read(path / "routes.txt")) else 1
def watch_feeds():
"""Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten
erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
ready = sorted(m.parent.name for m in C.FEEDS_DIR.glob("*/meta.json"))
if not ready:
return
want = S.rt_version if S.rt_version in ready else ready[-1]
"""Lädt je Quelle den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (Schweiz:
die schalten erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
today = dt.datetime.now(TZ).date()
if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)):
return
log.info("Lade Fahrplan %s …", want)
t = time.time()
S.feed = Feed(C.FEEDS_DIR / want)
log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids))
found = {}
for src in S.sources.values(): # erst alle markieren, damit die Karte nicht mit einem Land allein startet
found[src.name] = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
src.on_disk = bool(found[src.name])
for src in S.sources.values():
if not found[src.name]:
continue
metas = {v: json.loads((C.FEEDS_DIR / src.name / v / "meta.json").read_text()) for v in found[src.name]}
# nur Versionen im neusten Aufbereitungs-Format (sonst z. B. alter Fahrplan ohne Trams, nur weil
# die Echtzeit sich noch auf ihn bezieht)
fmts = {v: feed_format(C.FEEDS_DIR / src.name / v, m) for v, m in metas.items()}
ready = [v for v in found[src.name] if fmts[v] == max(fmts.values())]
want = src.rt_version if src.rt_version in ready else ready[-1]
meta = metas[want]
if (src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1))
and src.feed.meta.get("prepared_at") == meta.get("prepared_at")): # gleiche Version neu aufbereitet?
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want, all_sbahn=src.cfg.get("all_sbahn", False))
src.loaded_at = time.time()
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime(src)
except Exception:
log.exception("[%s] Erster Echtzeit-Abruf fehlgeschlagen", src.name)
compute_positions()
def poll_realtime():
if not C.RT_KEY or not S.feed:
def poll_realtime(src: Source):
if not src.has_rt or not src.feed:
return
try:
S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index)
S.rt_at, S.rt_error = time.time(), None
merged, version = {}, None
for url in src.cfg["rt_url"].split(): # z. B. Niederlande: Züge und Trams in getrennten Feeds
v, part = realtime.parse(realtime.fetch(url, src.cfg["rt_key"]), src.feed.trip_index)
merged.update(part)
version = version or v
src.rt_version, src.rt = version, merged
src.rt_at, src.rt_error = time.time(), None
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
S.rt_error = str(e)
if S.rt_at and time.time() - S.rt_at > 600:
S.rt = {}
src.rt_error = str(e)
if src.rt_at and time.time() - src.rt_at > 600:
src.rt = {}
raise
finally:
src.rt_tried_at = time.time()
def positions(src: Source, t: float, ahead: tuple = ()) -> list:
feats = []
for f in src.feed.vehicles(t, src.rt, ahead):
if len(S.sources) > 1 and not border.shows(C.country(src.name), {C.country(n) for n in S.sources},
*f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
feats.append(f)
return feats
def compute_positions():
if not S.feed:
return
now = time.time()
feats = S.feed.vehicles(now, S.rt)
# Position in AHEAD s mitliefern: der Browser fährt die Züge damit flüssig dazwischen
ahead = {f["properties"]["id"]: f["geometry"]["coordinates"] for f in S.feed.vehicles(now + AHEAD, S.rt)}
for f in feats:
f["next"] = ahead.get(f["properties"]["id"])
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "ahead": AHEAD, "features": feats},
feats = []
for src in list(S.sources.values()):
if not src.feed:
continue
# Distanz entlang der Strecke jetzt und alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
cur = positions(src, now, tuple(STEP * (k + 1) for k in range(STEPS)))
for f in cur:
f["path"] = src.feed.track(f.pop("_ti"), [f.pop("_dist"), *f.pop("_ahead")], STEP)
feats += cur
# Im übrigen Ausland (Luxemburg, Österreich, …) zeigen alle Fahrpläne ihre Züge -> dort Doppelte
# (gleiche Linie, gleiche ~500-m-Zelle) entfernen, bevorzugt den mit Echtzeit behalten
seen, out = set(), []
for f in sorted(feats, key=lambda f: f["properties"]["delay"] is None):
x, y = f["geometry"]["coordinates"]
if border.country(x, y) is None:
line, cx, cy = f["properties"]["line"], round(x * 200), round(y * 200)
if any((line, cx + i, cy + j) in seen for i in (-1, 0, 1) for j in (-1, 0, 1)):
continue
seen.add((line, cx, cy))
out.append(f)
rail = [f for f in out if f["properties"]["mode"] == "rail"]
S.urban = (now, [(*f["geometry"]["coordinates"], json.dumps(f, separators=(",", ":")).encode())
for f in out if f["properties"]["mode"] != "rail"])
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": rail},
separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5)
S.count, S.computed_at = len(feats), time.time()
S.count, S.computed_at = len(rail), time.time()
@asynccontextmanager
async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # jeder Echtzeit-Abruf, z. T. mit Key in der Adresse
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
loop(60, watch_feeds)
loop(C.RT_INTERVAL, poll_realtime)
for src in S.sources.values():
loop(src.cfg["rt_interval"], poll_realtime, src)
loop(3, compute_positions)
yield
@@ -107,6 +186,16 @@ async def lifespan(app):
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
@app.middleware("http")
async def no_stale_frontend(request: Request, call_next):
# Nach einem Update sonst alte app.js aus dem Browser-Cache; mit no-cache fragt der Browser
# jedes Mal kurz nach (304, wenn unverändert). Kacheln dürfen normal gecacht werden.
resp = await call_next(request)
if not request.url.path.startswith(("/tiles/", "/api/")):
resp.headers["Cache-Control"] = "no-cache"
return resp
@app.get("/api/vehicles")
def vehicles(request: Request):
headers = {"Cache-Control": "no-store", "X-Now": str(time.time())} # für den Uhrenabgleich im Browser
@@ -115,26 +204,55 @@ def vehicles(request: Request):
return Response(S.vehicles, media_type="application/json", headers=headers)
@app.get("/api/trip/{date}/{ti}")
def trip(date: str, ti: int):
f = S.feed
@app.get("/api/urban")
def urban(request: Request, bbox: str):
"""Trams und U-Bahnen im Ausschnitt "west,süd,ost,nord"."""
try:
w, s, e, n = (float(v) for v in bbox.split(","))
except ValueError:
raise HTTPException(400, "bbox=west,süd,ost,nord")
ts, items = S.urban
feats = [j for x, y, j in items if w <= x <= e and s <= y <= n]
body = b'{"type":"FeatureCollection","ts":%.3f,"features":[%s]}' % (ts, b",".join(feats))
headers = {"Cache-Control": "no-store", "X-Now": str(time.time())}
if "gzip" in request.headers.get("accept-encoding", ""):
return Response(gzip.compress(body, 5), media_type="application/json", headers={**headers, "Content-Encoding": "gzip"})
return Response(body, media_type="application/json", headers=headers)
@app.get("/api/trip/{source}/{date}/{ti}")
def trip(source: str, date: str, ti: int):
src = S.sources.get(source)
f = src.feed if src else None
if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
raise HTTPException(404)
return f.trip_detail(date, ti, time.time(), S.rt)
return f.trip_detail(date, ti, time.time(), src.rt)
@app.get("/api/status")
def status():
f = S.feed
loaded = [s for s in S.sources.values() if s.feed]
pending = [s for s in S.sources.values() if s.on_disk and not s.feed] # nicht halbe Karte zeigen
rt = [s for s in loaded if s.has_rt]
ok = [s for s in rt if not s.rt_error and s.rt_at and time.time() - s.rt_at < 180]
return {
"feed_version": f.version if f else None,
"shapes": f.meta.get("shapes") if f else None,
"realtime": bool(C.RT_KEY),
"rt_version": S.rt_version,
"rt_age": round(time.time() - S.rt_at) if S.rt_at else None,
"rt_error": S.rt_error,
"rt_trips": len(S.rt),
"loaded": bool(loaded),
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
"ready": bool(loaded and not pending and S.computed_at
and all(S.computed_at >= s.loaded_at for s in loaded) and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"urban": len(S.urban[1]),
"rt_ok": bool(ok) and len(ok) == len(loaded),
"rt_some": bool(ok),
"sources": {s.name: {
"feed_version": s.feed.version if s.feed else None,
"shapes": s.feed.meta.get("shapes") if s.feed else None,
"realtime": s.has_rt,
"rt_version": s.rt_version,
"rt_age": round(time.time() - s.rt_at) if s.rt_at else None,
"rt_error": s.rt_error,
"rt_trips": len(s.rt),
} for s in S.sources.values()},
}
+375 -74
View File
@@ -4,22 +4,47 @@ const EMPTY = { type: "FeatureCollection", features: [] };
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const flavor = basemaps.namedFlavor("light");
const baseLayers = basemaps.layers("osm", flavor, { lang: "de" });
const labelIds = new Set(basemaps.layers("osm", flavor, { labelsOnly: true, lang: "de" }).map((l) => l.id));
// Drei Grundkarten-Sätze im selben Style, umgeschaltet per Sichtbarkeit:
// hell, dunkel, und Beschriftung für Satellit (weisse Schrift mit dunklem Rand, wie bei Google Maps)
// Orte: ohne weissen Punkt, Name direkt auf der Ortslage statt daneben
function centerPlace(l) {
if (l.id !== "places_locality") return l.layout;
const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout;
return { ...rest, "text-anchor": "center" };
}
const prefixed = (layers, pre, extra = {}) => layers.filter((l) => !["roads_shields", "pois"].includes(l.id)).map((l) => ({ ...l, id: pre + l.id,
layout: { ...centerPlace(l), visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
const LAYERS = {
light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"),
dark: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { lang: "de" }), "d-"),
sat: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { labelsOnly: true, lang: "de" }), "s-",
{ "text-color": "#ffffff", "text-halo-color": "rgba(0,0,0,0.85)", "text-halo-width": 1.6, "text-halo-blur": 0.5 }),
};
// Verspätung in Sekunden -> Farbe (SBB zählt < 3 min als pünktlich)
const delayColor = ["case",
["==", ["get", "delay"], null], "#94a3b8",
["<", ["get", "delay"], 180], "#16a34a",
["<", ["get", "delay"], 360], "#eab308",
["<", ["get", "delay"], 900], "#f97316",
"#dc2626"];
// Verspätung (s) -> Farbe, fliessend: bis +2′ grün (wie SBB "pünktlich"), dann über gelb (10′),
// orange (20′), rot (30′) zu lila (40′); ab 50′ blinkt das Symbol zusätzlich (BLINK_S)
const DELAY_STOPS = [[150, "#16a34a"], [600, "#eab308"], [1200, "#f97316"], [1800, "#dc2626"], [2400, "#9333ea"]];
const NO_RT = "#94a3b8", BLINK_S = 3000;
const delayColor = ["case", ["==", ["get", "delay"], null], NO_RT,
["interpolate", ["linear"], ["get", "delay"], ...DELAY_STOPS.flat()]];
function delayRGB(sec) { // dasselbe für die Halteliste (Badges, +x′)
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
if (sec <= DELAY_STOPS[0][0]) return DELAY_STOPS[0][1];
for (let i = 1; i < DELAY_STOPS.length; i++) {
const [s0, c0] = DELAY_STOPS[i - 1], [s1, c1] = DELAY_STOPS[i];
if (sec <= s1) {
const k = (sec - s0) / (s1 - s0), a = hex(c0), b = hex(c1);
return `rgb(${a.map((v, j) => Math.round(v + (b[j] - v) * k)).join(",")})`;
}
}
return DELAY_STOPS.at(-1)[1];
}
const map = new maplibregl.Map({
container: "map",
center: [8.23, 46.82],
zoom: 7.4,
center: [9.0, 50.5], // Mitteleuropa; Skandinavien und Spanien per Verschieben
zoom: 4.6,
hash: true,
style: {
version: 8,
@@ -41,29 +66,51 @@ const map = new maplibregl.Map({
attribution: '© <a href="https://www.swisstopo.admin.ch">swisstopo</a>',
},
route: { type: "geojson", data: EMPTY },
"route-stops": { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0), '
+ '<a href="https://transport.data.gouv.fr/datasets/horaires-sncf">SNCF</a> (ODbL), '
+ '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data">NMBS/SNCB</a>, '
+ '<a href="https://data.renfe.com">Renfe</a>, <a href="https://mkuran.pl/gtfs/">mkuran.pl</a> (PKP PLK), '
+ '<a href="https://developer.entur.org">Entur</a> (NLOD), <a href="https://www.rejseplanen.dk">Rejseplanen</a>, '
+ '<a href="https://data.oebb.at">ÖBB</a>, <a href="https://www.data.gv.at">Wiener Linien</a> (CC BY 4.0), '
+ '<a href="https://www.trafiklab.se">Trafiklab</a>',
},
},
layers: [
{ id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } },
{ id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } },
...baseLayers,
...LAYERS.light, ...LAYERS.dark, ...LAYERS.sat,
{ id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } },
{ id: "trains", type: "circle", source: "trains",
{ id: "route-stops", type: "circle", source: "route-stops", // Halte: Linie dort etwas dicker
paint: { "circle-color": "#2563eb", "circle-radius": ["interpolate", ["linear"], ["zoom"], 5, 4.5, 12, 7] } },
{ id: "trains", type: "symbol", source: "trains",
layout: {
// Tropfen mit Spitze in Fahrtrichtung; Züge ohne bekannte Richtung als Punkt
"icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map",
// Trams/U-Bahnen etwas kleiner als Züge
// (zoom darf nur zuoberst in interpolate stehen, darum der Faktor in jedem Stützwert)
"icon-size": ["interpolate", ["linear"], ["zoom"],
...[[6, 0.3], [9, 0.5], [12, 0.9], [14, 1.3]].flatMap(([z, v]) => [z, ["*", v, ["case", ["==", ["get", "mode"], "rail"], 1, 0.75]]])],
"icon-allow-overlap": true, "icon-ignore-placement": true,
},
paint: {
"circle-color": delayColor,
"circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 3, 10, 5.5, 14, 9],
"circle-stroke-color": "#fff",
"circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 12, 1.8],
"icon-color": delayColor, "icon-halo-color": "#fff",
// Rand muss mit der Symbolgrösse skalieren, sonst füllt er bei kleinem Zoom das ganze Quadrat
"icon-halo-width": ["interpolate", ["linear"], ["zoom"], 6, 0.8, 9, 1.4, 12, 2.4, 14, 3.4],
} },
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
layout: {
"text-field": ["concat", ["get", "line"], " ", ["get", "nr"]],
"text-field": ["get", "line"],
"text-font": ["Noto Sans Medium"], "text-size": 11,
"text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true,
"text-offset": [0, 1.1], "text-anchor": "top",
// nicht bei jedem Animationsschritt neu auf Überschneidung prüfen, sonst flackert die Linie
"text-allow-overlap": true, "text-ignore-placement": true,
},
paint: { "text-color": "#111827", "text-halo-color": "#fff", "text-halo-width": 1.5 } },
],
@@ -72,49 +119,215 @@ const map = new maplibregl.Map({
map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "bottom-right");
map.addControl(new maplibregl.ScaleControl(), "bottom-left");
// ---------- Karte / Satellit ----------
// ---------- Karte / Dunkel / Satellit ----------
function setBackground(sat) {
for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", sat ? "visible" : "none");
for (const l of baseLayers) {
if (!labelIds.has(l.id)) map.setLayoutProperty(l.id, "visibility", sat ? "none" : "visible");
}
document.getElementById("btn-map").setAttribute("aria-pressed", String(!sat));
document.getElementById("btn-sat").setAttribute("aria-pressed", String(sat));
try { localStorage.setItem("sat", sat ? "1" : ""); } catch (e) { /* privater Modus */ }
const MODES = ["light", "dark", "sat"];
function setMode(mode) {
if (!MODES.includes(mode)) mode = "light";
const show = (layers, on) => layers.forEach((l) => map.setLayoutProperty(l.id, "visibility", on ? "visible" : "none"));
show(LAYERS.light, mode === "light");
show(LAYERS.dark, mode === "dark");
show(LAYERS.sat, mode === "sat");
for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", mode === "sat" ? "visible" : "none");
const light = mode === "light";
map.setPaintProperty("train-labels", "text-color", light ? "#111827" : "#ffffff");
map.setPaintProperty("train-labels", "text-halo-color", light ? "#ffffff" : "rgba(0,0,0,0.85)");
map.setPaintProperty("trains", "icon-halo-color", light ? "#ffffff" : "#111827");
for (const m of MODES) document.getElementById("btn-" + m).setAttribute("aria-pressed", String(m === mode));
try { localStorage.setItem("mode", mode); } catch (e) { /* privater Modus */ }
}
document.getElementById("btn-map").onclick = () => setBackground(false);
document.getElementById("btn-sat").onclick = () => setBackground(true);
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Ebenen: Fernverkehr / Regionalzug / S-Bahn / Tram / U-Bahn, einzeln oder alle ----------
const LAYER_FILTERS = {
long: ["get", "fv"], regio: ["all", ["==", ["get", "mode"], "rail"], ["!", ["get", "fv"]], ["!", ["get", "sb"]]],
sbahn: ["get", "sb"],
tram: ["==", ["get", "mode"], "tram"], subway: ["==", ["get", "mode"], "subway"],
};
const ALL_LAYERS = Object.keys(LAYER_FILTERS);
const layerOf = (p) => (p.mode === "rail" ? (p.fv ? "long" : p.sb ? "sbahn" : "regio") : p.mode);
let shown = new Set(ALL_LAYERS);
function setLayers(list) {
shown = new Set(list.filter((k) => k in LAYER_FILTERS));
if (!shown.size) shown = new Set(ALL_LAYERS); // nichts gewählt = alles
const all = shown.size === ALL_LAYERS.length;
const filter = all ? null : ["any", ...[...shown].map((k) => LAYER_FILTERS[k])];
for (const id of ["trains", "train-labels"]) map.setFilter(id, filter);
document.getElementById("flt-all").setAttribute("aria-pressed", String(all));
for (const k of ALL_LAYERS) document.getElementById("flt-" + k).setAttribute("aria-pressed", String(!all && shown.has(k)));
updateHint();
try { localStorage.setItem("layers", JSON.stringify([...shown])); } catch (e) { /* privater Modus */ }
}
document.getElementById("flt-all").onclick = () => setLayers(ALL_LAYERS);
for (const k of ALL_LAYERS) document.getElementById("flt-" + k).onclick = () => {
// aus "Alle" heraus: nur diese Ebene; sonst Ebene dazu oder weg
if (shown.size === ALL_LAYERS.length) return setLayers([k]);
const next = new Set(shown);
next.has(k) ? next.delete(k) : next.add(k);
setLayers([...next]);
};
// ---------- Züge ----------
// Server liefert je Zug die Position jetzt und in `ahead` Sekunden; dazwischen wird linear gefahren.
// Server liefert je Zug die Gleispunkte der nächsten ~10 s, jeweils mit der Zeit, zu der der Zug dort ist;
// der Browser fährt sie ab, die Spitze dreht sich weich in die Richtung des aktuellen Gleisstücks.
let trains = { type: "FeatureCollection", features: [] };
let ts = 0, ahead = 10, clockOffset = 0;
let clockOffset = 0;
const URBAN_Z = 11; // Trams/U-Bahnen erst ab hier (vorher nur Gewimmel, und sonst zu viele Daten)
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
const now = Number(r.headers.get("X-Now"));
if (now) clockOffset = Date.now() / 1000 - now;
const d = await r.json();
for (const f of d.features) f.t0 = d.ts; // Gleispunkt-Zeiten zählen ab der Berechnung auf dem Server
return d.features;
}
function updateHint() {
// nur Trams/U-Bahnen gewählt, aber zu weit weg: sonst wäre die Karte einfach leer
const onlyCity = !["long", "regio", "sbahn"].some((k) => shown.has(k));
document.getElementById("hint").hidden = !(onlyCity && map.getZoom() < URBAN_Z);
}
map.on("zoomend", updateHint);
// beim Verschieben/Zoomen Trams nachladen, wenn der Ausschnitt den zuletzt geladenen verlässt
map.on("moveend", () => {
if (!ready) return;
const b = map.getBounds(), want = map.getZoom() >= URBAN_Z;
const inside = urbanBox && b.getWest() >= urbanBox[0] && b.getSouth() >= urbanBox[1]
&& b.getEast() <= urbanBox[2] && b.getNorth() <= urbanBox[3];
if (want ? !inside : urbanBox) refreshTrains();
});
let urbanBox = null; // zuletzt abgefragter Ausschnitt (mit Rand), um beim Verschieben nachzuladen
async function refreshTrains() {
if (document.hidden) return;
if (document.hidden || !ready) return;
try {
const r = await fetch("api/vehicles");
if (!r.ok) return;
const now = Number(r.headers.get("X-Now"));
const d = await r.json();
if (now) clockOffset = Date.now() / 1000 - now;
for (const f of d.features) f.start = f.geometry.coordinates;
trains = d;
ts = d.ts;
ahead = d.ahead || 10;
let urban = [];
if (map.getZoom() >= URBAN_Z) {
const b = map.getBounds(), dx = (b.getEast() - b.getWest()) * 0.3, dy = (b.getNorth() - b.getSouth()) * 0.3;
urbanBox = [b.getWest() - dx, b.getSouth() - dy, b.getEast() + dx, b.getNorth() + dy];
urban = await getJSON("api/urban?bbox=" + urbanBox.map((v) => v.toFixed(4)).join(","));
} else urbanBox = null;
const features = [...(await getJSON("api/vehicles")), ...urban];
const old = new Map(trains.features.map((f) => [f.properties.id, f]));
for (const f of features) {
f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts, m ab Fahrtbeginn]
const o = old.get(f.properties.id);
if (o) {
f.properties.bearing = o.properties.bearing;
f.geometry.coordinates = o.geometry.coordinates; // für "nicht rückwärts", siehe render()
f.shownD = o.shownD;
}
}
trains = { type: "FeatureCollection", features };
render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
}
// kürzester Drehwinkel von a nach b in Grad (-180..180)
const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
// Richtung von a nach b in Grad (0 = Norden), in Mercator wie die Karte; null wenn (fast) gleicher Punkt
function bearing(a, b) {
const y = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
const dx = ((b[0] - a[0]) * Math.PI) / 180, dy = y(b[1]) - y(a[1]);
return Math.hypot(dx, dy) < 1e-7 ? null : (Math.atan2(dx, dy) * 180) / Math.PI;
}
// SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann
const S = 64, R = 8;
function sdfIcon(mask) {
const img = new Uint8Array(S * S * 4);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const self = mask[y * S + x];
let d = R; // Abstand zur nächsten Kante, gesucht im Umkreis R
for (let j = -R; j <= R; j++) for (let i = -R; i <= R; i++) {
const xx = x + i, yy = y + j;
const other = xx >= 0 && yy >= 0 && xx < S && yy < S ? mask[yy * S + xx] : false;
if (other !== self) d = Math.min(d, Math.hypot(i, j) - 0.5);
}
const sd = self ? -d : d;
img[(y * S + x) * 4 + 3] = Math.max(0, Math.min(255, Math.round(255 * (1 - (sd / R + 0.25)))));
}
return { width: S, height: S, data: img };
}
// Pfeil-Form (Navigations-Cursor), um 45° gedreht, damit die Spitze nach oben (Norden) zeigt
const ARROW = new Path2D("M.008 183.043a38.09 38.09 0 0 0 26.772 36.18l196.737 61.813a10.72 10.72 0 0 1 7.44 7.453l61.82 196.731A38.11 38.11 0 0 0 328.953 512h.3a38.11 38.11 0 0 0 36.282-26.2L510.018 50.311A38.233 38.233 0 0 0 461.7 1.975L26.2 146.458A38.115 38.115 0 0 0 .008 183.043");
function shapeMask(draw, fit = 44) {
const c = document.createElement("canvas");
c.width = c.height = S;
const ctx = c.getContext("2d", { willReadFrequently: true });
const raster = (k, dx, dy) => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, S, S);
ctx.translate(S / 2 + dx, S / 2 + dy);
ctx.scale(k, k);
draw(ctx);
const a = ctx.getImageData(0, 0, S, S).data, m = [];
let x0 = S, x1 = 0, y0 = S, y1 = 0;
for (let i = 0; i < S * S; i++) {
m.push(a[i * 4 + 3] > 127);
if (m[i]) { const x = i % S, y = (i / S) | 0; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
}
return { m, x0, x1, y0, y1 };
};
let b = raster(0.04, 0, 0); // erst messen, dann skalieren und zentrieren
const k = 0.04 * fit / Math.max(b.x1 - b.x0 + 1, b.y1 - b.y0 + 1);
b = raster(k, 0, 0);
return raster(k, S / 2 - (b.x0 + b.x1 + 1) / 2, S / 2 - (b.y0 + b.y1 + 1) / 2).m;
}
map.addImage("tp-arrow", sdfIcon(shapeMask((ctx) => {
ctx.rotate(-Math.PI / 4);
ctx.translate(-256, -256);
ctx.fill(ARROW);
})), { sdf: true, pixelRatio: 2 });
map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
ctx.beginPath();
ctx.arc(0, 0, 100, 0, 2 * Math.PI);
ctx.fill();
}, 26)), { sdf: true, pixelRatio: 2 });
let lastRender = 0;
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
function render() {
// leicht über `ahead` hinaus weiterfahren, falls eine Abfrage hängt, dann anhalten
const k = Math.max(0, Math.min((Date.now() / 1000 - clockOffset - ts) / ahead, 1.5));
const now = Date.now() / 1000 - clockOffset;
const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen
lastRender = performance.now();
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
for (const f of trains.features) {
if (!f.next) continue;
f.geometry.coordinates = [f.start[0] + (f.next[0] - f.start[0]) * k, f.start[1] + (f.next[1] - f.start[1]) * k];
const p = f.pts, t = now - f.t0; // s seit Berechnung auf dem Server
if (!p) continue;
// Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
let i = 0;
while (i + 1 < p.length && p[i + 1][2] <= t) i++;
const a = p[i], b = p[Math.min(i + 1, p.length - 1)];
const k = b[2] > a[2] ? Math.max(0, Math.min(1, (t - a[2]) / (b[2] - a[2]))) : 0;
// Steigt die Verspätung (Echtzeit kommt in Minutenschritten), läge der Zug plötzlich hinter der
// gezeigten Stelle: dann warten statt zurückspringen, bis er wieder dort ist (grosse Korrekturen ausgenommen)
const d = a[3] === undefined ? undefined : a[3] + (b[3] - a[3]) * k;
if (d !== undefined && f.shownD !== undefined && d < f.shownD && f.shownD - d < MAX_HOLD_M) continue;
f.shownD = d;
f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
// Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt)
const target = bearing(a, b) ?? f.properties.heading;
if (target === null || target === undefined) continue;
const cur = f.properties.bearing;
f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease;
}
map.getSource("trains").setData(trains);
// ab 50′ Verspätung sanft ein- und ausblenden (ein Zyklus ~1,6 s)
const pulse = 0.55 + 0.45 * Math.cos((performance.now() / 1600) * 2 * Math.PI);
map.setPaintProperty("trains", "icon-opacity", ["case", [">=", ["coalesce", ["get", "delay"], 0], BLINK_S], pulse, 1]);
if (hoverId) { // Hover-Kärtchen fährt mit dem Zug mit
const f = trains.features.find((x) => x.properties.id === hoverId);
if (f) hover.setLngLat(f.geometry.coordinates); else { hover.remove(); hoverId = null; }
}
}
let lastFrame = 0;
@@ -127,25 +340,37 @@ function animate(ms) {
}
async function refreshStatus() {
const el = document.getElementById("status");
const el = document.getElementById("status"), loading = document.getElementById("loading");
try {
const s = await (await fetch("api/status")).json();
if (!s.feed_version) { el.textContent = "Noch kein Fahrplan geladen …"; el.className = "warn"; return; }
const v = s.feed_version;
const parts = [`Fahrplan ${v.slice(6, 8)}.${v.slice(4, 6)}.${v.slice(0, 4)}`, `${s.vehicles} Züge`];
let warn = !s.shapes;
if (!s.realtime) { parts.push("keine Echtzeit (API-Key fehlt)"); warn = true; }
else if (s.rt_error || s.rt_age === null || s.rt_age > 180) { parts.push("Echtzeit gestört"); warn = true; }
else parts.push(`Echtzeit vor ${s.rt_age} s`);
if (!s.shapes) parts.push("Strecken: Luftlinie");
el.textContent = parts.join(" · ");
el.className = warn ? "warn" : "";
// beim Start oder nach Server-Neustart (noch keine Züge); sonst meldet der Server nach jedem
// Echtzeit-Abruf nur kurz "nicht bereit"
if (!s.ready && (!ready || !s.vehicles)) {
loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden.";
loading.hidden = false;
return;
}
if (!ready) {
ready = true;
await refreshTrains();
}
loading.hidden = true; // auch nach "Keine Verbindung" wieder weg
const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge` + (s.urban ? `, ${s.urban} Trams/U-Bahnen` : "") + " unterwegs" + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn";
} catch (e) {
el.textContent = "Server nicht erreichbar";
el.className = "warn";
loading.textContent = "Keine Verbindung zum Server …";
loading.hidden = false;
}
}
async function statusLoop() { // bis alles bereit ist alle 2 s nachfragen, danach alle 30 s
await refreshStatus();
setTimeout(statusLoop, ready && document.getElementById("loading").hidden ? 30000 : 2000);
}
// ---------- Zugdetails ----------
let openId = null;
@@ -154,15 +379,13 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<
function delayBadge(sec) {
if (sec === null || sec === undefined) return '<span class="badge" style="background:var(--na)">ohne Echtzeit</span>';
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="badge" style="background:var(${c})">${min <= 0 ? "pünktlich" : "+" + min + "′"}</span>`;
return `<span class="badge" style="background:${delayRGB(sec)}">${min <= 0 ? "pünktlich" : "+" + min + "′"}</span>`;
}
function delayText(sec) {
if (sec === null || sec === undefined || Math.round(sec / 60) === 0) return "";
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="d" style="color:var(${c})">${min > 0 ? "+" : ""}${min}′</span>`;
return `<span class="d" style="color:${delayRGB(sec)}">${min > 0 ? "+" : ""}${min}′</span>`;
}
async function showTrip(id) {
@@ -176,35 +399,113 @@ async function showTrip(id) {
<h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2>
<p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p>
<ul class="stops">${t.stops.map((s) => `
<li class="${s.passed ? "passed" : ""}">
<li class="${s.passed ? "passed" : ""} ${s.skipped ? "skipped" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span>
${delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
${s.skipped ? '<span class="d" style="color:var(--d3)">fällt aus</span>' : delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
</li>`).join("")}
</ul>`;
document.getElementById("panel").hidden = false;
map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } });
map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => (
{ type: "Feature", properties: { name: x.name }, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
}
// ---------- Suche ----------
// Gleiche Linie fährt oft mehrfach gleichzeitig (S5 in Zürich, Bern, München …): darum je Treffer
// Start → Ziel und nächster Halt, die nächstgelegenen zuerst
const norm = (s) => String(s ?? "").toLowerCase().replace(/\s+/g, "");
let hits = [], sel = -1;
function search() {
const q = norm(document.getElementById("q").value), list = document.getElementById("results");
if (!q) { list.hidden = true; hits = []; return; }
const c = map.getCenter(), kx = Math.cos((c.lat * Math.PI) / 180);
const dist = (f) => Math.hypot((f.geometry.coordinates[0] - c.lng) * kx, f.geometry.coordinates[1] - c.lat);
hits = trains.features.map((f) => {
const p = f.properties, line = norm(p.line);
const rank = line === q || norm(p.nr) === q ? 0 : line.startsWith(q) ? 1
: norm(p.to).includes(q) || norm(p.from).includes(q) ? 2 : -1;
return [rank, dist(f), f];
}).filter((h) => h[0] >= 0).sort((a, b) => a[0] - b[0] || a[1] - b[1]).slice(0, 30).map((h) => h[2]);
sel = -1;
list.innerHTML = hits.length ? hits.map((f, i) => {
const p = f.properties;
return `<li role="option" data-i="${i}"><div class="r1"><b>${esc(p.line)}${p.nr ? " " + esc(p.nr) : ""}</b>${delayBadge(p.delay)}</div>
<div class="r2">${esc(p.from)} → ${esc(p.to)}</div><div class="r2">nächster Halt: ${esc(p.next)}</div></li>`;
}).join("") : '<li class="r2">kein fahrender Zug gefunden</li>';
list.hidden = false;
}
function pick(i) {
const f = hits[i];
if (!f) return;
const now = trains.features.find((x) => x.properties.id === f.properties.id) || f; // aktuelle Position
if (!shown.has(layerOf(f.properties))) setLayers([...shown, layerOf(f.properties)]); // sonst unsichtbar
map.flyTo({ center: now.geometry.coordinates, zoom: Math.max(map.getZoom(), 12) });
showTrip(f.properties.id);
document.getElementById("results").hidden = true;
}
const qEl = document.getElementById("q");
qEl.addEventListener("input", search);
qEl.addEventListener("focus", search);
qEl.addEventListener("keydown", (e) => {
const items = document.querySelectorAll("#results li[data-i]");
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!items.length) return;
sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items.forEach((li, i) => li.classList.toggle("sel", i === sel));
items[sel].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") pick(sel >= 0 ? sel : 0);
else if (e.key === "Escape") { document.getElementById("results").hidden = true; qEl.blur(); }
});
document.getElementById("results").addEventListener("click", (e) => {
const li = e.target.closest("li[data-i]");
if (li) pick(Number(li.dataset.i));
});
map.on("click", () => { document.getElementById("results").hidden = true; });
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
map.getSource("route").setData(EMPTY);
map.getSource("route-stops").setData(EMPTY);
}
document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id));
map.on("mouseenter", "trains", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; });
const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: "hover" });
let hoverId = null;
function hoverHtml(p) {
const delay = p.delay === undefined || p.delay === null ? "" : delayText(Number(p.delay)) || '<span class="d" style="color:var(--ok)">pünktlich</span>';
return `<b>${esc(p.line)} ${esc(p.nr)}</b> ${delay}<br>nach ${esc(p.to)}`;
}
map.on("mousemove", "trains", (e) => {
map.getCanvas().style.cursor = "pointer";
const p = e.features[0].properties;
if (p.id !== hoverId) hover.setHTML(hoverHtml(p));
hoverId = p.id;
hover.setLngLat(e.features[0].geometry.coordinates).addTo(map);
});
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); hoverId = null; });
// Halte der geöffneten Fahrt: Name beim Darüberfahren
const stopHover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 8, className: "hover" });
map.on("mousemove", "route-stops", (e) => {
if (hoverId) return; // Zug-Kärtchen hat Vorrang
stopHover.setText(e.features[0].properties.name).setLngLat(e.features[0].geometry.coordinates).addTo(map);
});
map.on("mouseleave", "route-stops", () => stopHover.remove());
map.on("load", () => {
let sat = false;
try { sat = localStorage.getItem("sat") === "1"; } catch (e) { /* egal */ }
setBackground(sat);
refreshTrains();
refreshStatus();
let mode = "light";
try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ }
setMode(mode);
let layers = ALL_LAYERS;
try { layers = JSON.parse(localStorage.getItem("layers")) || ALL_LAYERS; } catch (e) { /* egal */ }
setLayers(Array.isArray(layers) ? layers : ALL_LAYERS);
statusLoop();
setInterval(refreshTrains, 5000);
requestAnimationFrame(animate);
setInterval(refreshStatus, 30000);
setInterval(() => openId && showTrip(openId), 30000);
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32 3 L45.6 23.4 A17 17 0 1 1 18.4 23.4 Z" fill="#b91c1c" stroke="#fff" stroke-width="3" stroke-linejoin="round"/><circle cx="32" cy="38" r="6" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 233 B

+22 -6
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TrainPulse</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@4.5.0/dist/pmtiles.js"></script>
@@ -16,19 +17,34 @@
<header class="card" id="bar">
<h1>TrainPulse</h1>
<div class="seg" role="group" aria-label="Hintergrund">
<button id="btn-map" aria-pressed="true">Karte</button>
<button id="btn-light" aria-pressed="true">Karte</button>
<button id="btn-dark" aria-pressed="false">Dunkel</button>
<button id="btn-sat" aria-pressed="false">Satellit</button>
</div>
<div class="seg" role="group" aria-label="Verkehrsmittel">
<button id="flt-all" aria-pressed="true">Alle</button>
<button id="flt-long" aria-pressed="false" title="Fernverkehr (ICE, IC, TGV …)">Fern</button>
<button id="flt-regio" aria-pressed="false" title="Regionalzüge (RE, RB, TER, Sprinter …)">Regio</button>
<button id="flt-sbahn" aria-pressed="false">S-Bahn</button>
<button id="flt-tram" aria-pressed="false">Tram</button>
<button id="flt-subway" aria-pressed="false">U-Bahn</button>
</div>
<div id="search">
<input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off"
aria-label="Zug suchen" aria-controls="results">
<ul id="results" role="listbox" hidden></ul>
</div>
<ul class="legend">
<li><i style="--c:var(--ok)"></i>pünktlich</li>
<li><i style="--c:var(--d1)"></i>3–6′</li>
<li><i style="--c:var(--d2)"></i>6–15′</li>
<li><i style="--c:var(--d3)"></i>&gt;15′</li>
<li class="scale" aria-label="Verspätung: grün pünktlich, gelb 10, orange 20, rot 30, lila ab 40 Minuten, ab 50 blinkend">
<span class="bar"></span><span class="ticks"><b>0</b><b>10</b><b>20</b><b>30</b><b>40</b><b>50′+</b></span></li>
<li><i style="--c:var(--na)"></i>ohne Echtzeit</li>
</ul>
<p id="status">Lade …</p>
<p id="status"></p>
<p id="hint" hidden>Trams und U-Bahnen erscheinen beim Hineinzoomen.</p>
</header>
<div class="card" id="loading" role="status">Züge werden geladen …</div>
<aside class="card" id="panel" hidden>
<button id="close" aria-label="Schliessen">×</button>
<div id="detail"></div>
+31 -3
View File
@@ -14,18 +14,35 @@ html, body { margin: 0; height: 100%; }
position: absolute; z-index: 2; background: var(--bg); border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,.18);
}
#bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(340px, calc(100vw - 24px)); box-sizing: border-box; }
#bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(380px, calc(100vw - 24px)); box-sizing: border-box; }
h1 { margin: 0 0 8px; font-size: 16px; letter-spacing: .02em; }
h1::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); margin-right: 7px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 14px; font: inherit; cursor: pointer; }
.seg + .seg { margin-top: 6px; }
.seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 8px; font: inherit; cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
.legend { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--muted); }
/* Skala 0–50 Minuten, gleiche Stützpunkte wie DELAY_STOPS in app.js */
.legend .scale { flex: 1 1 160px; }
.legend .bar { display: block; height: 7px; border-radius: 4px;
background: linear-gradient(90deg, #16a34a 5%, #eab308 20%, #f97316 40%, #dc2626 60%, #9333ea 80%, #9333ea); }
.legend .ticks { display: flex; justify-content: space-between; font-size: 11px; }
.legend .ticks b { font-weight: normal; }
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--c); margin-right: 4px; }
#search { margin-top: 8px; }
#q { width: 100%; box-sizing: border-box; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--fg); font: inherit; }
#results { list-style: none; margin: 4px 0 0; padding: 0; max-height: 45vh; overflow: auto; }
#results[hidden] { display: none; }
#results li { padding: 6px 4px; border-top: 1px solid var(--line); cursor: pointer; }
#results li:hover, #results li.sel { background: var(--line); }
#results .r1 { display: flex; justify-content: space-between; gap: 8px; }
#results .r2 { font-size: 12px; color: var(--muted); }
#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
#status.warn { color: var(--d2); }
#hint { margin: 4px 0 0; font-size: 12px; color: var(--muted); }
#panel { top: 12px; right: 12px; width: 340px; max-height: calc(100% - 24px); overflow: auto; padding: 14px 16px; box-sizing: border-box; }
#close { position: sticky; top: 0; float: right; border: 0; background: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; }
@@ -35,6 +52,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
.stops { list-style: none; margin: 0; padding: 0; }
.stops li { display: grid; grid-template-columns: 46px 1fr auto; gap: 0 8px; padding: 5px 0; border-top: 1px solid var(--line); }
.stops li.passed { opacity: .45; }
.stops li.skipped > span:nth-child(-n+2) { text-decoration: line-through; }
.stops .t { font-variant-numeric: tabular-nums; }
.stops .d { font-size: 12px; font-weight: 600; }
.stops .pl { color: var(--muted); font-size: 12px; }
@@ -43,3 +61,13 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
#panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; }
.legend { display: none; }
}
.hover .maplibregl-popup-content { background: var(--bg); color: var(--fg); padding: 6px 10px; border-radius: 8px; font-size: 13px; }
.hover .maplibregl-popup-tip { display: none; }
#loading { left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 14px 20px; font-size: 15px;
max-width: calc(100vw - 48px); text-align: center; z-index: 3; }
#loading[hidden] { display: none; }
#loading::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 10px; vertical-align: -2px;
border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }