Compare commits
30
Commits
0cd2c3951a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fa1918cd | ||
|
|
ef67df6e00 | ||
|
|
650f8dca8c | ||
|
|
be0de5d619 | ||
|
|
e948a69c32 | ||
|
|
b5350ae22e | ||
|
|
a7daa76535 | ||
|
|
d11cc109f6 | ||
|
|
49860c50f5 | ||
|
|
4d70cae372 | ||
|
|
811480b4d3 | ||
|
|
1dd39c08db | ||
|
|
53b7c24a81 | ||
|
|
ae50ac39ac | ||
|
|
deaa6aa9cd | ||
|
|
7ece7603a9 | ||
|
|
d339da5da2 | ||
|
|
76a8fc0806 | ||
|
|
8865e7abcf | ||
|
|
085a129a85 | ||
|
|
c07a309045 | ||
|
|
2a4962c379 | ||
|
|
2e3c10a6c4 | ||
|
|
4df29e7e6c | ||
|
|
a064fccb23 | ||
|
|
71d71dabcb | ||
|
|
b013753fc3 | ||
|
|
4447c83ef4 | ||
|
|
30d3127b21 | ||
|
|
88fd24c22b |
@@ -0,0 +1,5 @@
|
||||
.venv
|
||||
.git
|
||||
data
|
||||
.env
|
||||
**/__pycache__
|
||||
@@ -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=
|
||||
@@ -1,3 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
.env
|
||||
|
||||
+28
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
|
||||
attribution: 'Fahrplan & 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) => ({ "&": "&", "<
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>>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
@@ -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); } }
|
||||
|
||||
Reference in New Issue
Block a user