WIP: Deutschland als zweite Datenquelle (gtfs.de)

- mehrere Quellen (ch, de) mit je eigenem Fahrplan und Echtzeit-Feed
- OSM CH+DE, mit osmium auf Schienennetz gefiltert, für pfaedle
- Landesgrenze CH gegen doppelte grenzüberschreitende Züge
- Import robust gegen andere Spaltenreihenfolge (gtfs.de)
- Kacheln für CH+DE (Zoom 14)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-21 21:27:54 +02:00
parent 2e3c10a6c4
commit 2a4962c379
11 changed files with 370 additions and 168 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ FROM golang:1.25-trixie AS pmtiles
RUN go install github.com/protomaps/go-pmtiles@v1.31.2 RUN go install github.com/protomaps/go-pmtiles@v1.31.2
FROM python:3.13-slim-trixie FROM python:3.13-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 \ 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/* && rm -rf /var/lib/apt/lists/*
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/ COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
+28 -16
View File
@@ -1,23 +1,29 @@
# TrainPulse – Schweiz # TrainPulse
Live-Karte aller Züge in der Schweiz: Position entlang der echten Strecke, Linie, Zugnummer, Live-Karte aller Züge in der Schweiz und in Deutschland: Position entlang der echten Strecke,
Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
(OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2). dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
## Wie es funktioniert ## Wie es funktioniert
``` ```
opentransportdata.swiss ──GTFS (Mo+Do)──▶ updater-Container (stündlich) opentransportdata.swiss ──GTFS (Mo+Do)──┐
├─ Filter: nur Bahn (route_type 100–199) gtfs.de (DELFI) ─────────GTFS (täglich)──┼▶ updater-Container (stündlich)
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe │ ├─ Filter: nur Bahn (route_type 2, 100–199)
└─▶ data/feeds/<version>/ OpenStreetMap CH + DE (Geofabrik) ───────┴▶ ├─ osmium: nur Schienennetz, pfaedle: Streckenverläufe
opentransportdata.swiss ──GTFS-RT (35 s)──▶ web-Container (Port 8080) └─▶ data/feeds/<ch|de>/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──┐
gtfs.de ─────────────────GTFS-RT (60 s)───┴▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke ├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche └─ /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 Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
und gemeldeter Verspätung berechnet und auf den Streckenverlauf gelegt. Verspätung berechnet und auf den Streckenverlauf gelegt.
Beide Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt der
Schweizer Fahrplan nur Züge innerhalb der Schweiz und der deutsche nur Züge ausserhalb
(Landesgrenze aus OpenStreetMap, `trainpulse/ch_border.json`).
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr, 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 die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
@@ -57,7 +63,7 @@ hinter deinen bestehenden Reverse Proxy hängen.
| Status | `curl localhost:8080/api/status` | | Status | `curl localhost:8080/api/status` |
| Logs Webserver | `docker compose logs -f web` | | Logs Webserver | `docker compose logs -f web` |
| Logs Fahrplan-Update | `docker compose logs -f updater` | | Logs Fahrplan-Update | `docker compose logs -f updater` |
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --force` | | 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` | | Selbsttest | `docker compose exec web python tests/test_core.py` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` | | Stoppen / Starten | `docker compose down` / `docker compose up -d` |
@@ -65,7 +71,9 @@ hinter deinen bestehenden Reverse Proxy hängen.
Die Hinweise erscheinen unten rechts auf der Karte. 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)
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com) - 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) - Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
- Satellit Schweiz: © swisstopo (frei, Quellenangabe) - Satellit Schweiz: © swisstopo (frei, Quellenangabe)
@@ -73,9 +81,13 @@ Die Hinweise erscheinen unten rechts auf der Karte.
## Bekannte Grenzen ## Bekannte Grenzen
- Nur Bahn. Busse, Trams und Schiffe sind im Fahrplan, werden aber noch herausgefiltert. - Nur Bahn. Busse, Trams und U-Bahnen sind im Fahrplan, werden aber noch herausgefiltert.
- Der Schweizer Fahrplan enthält auch einige ausländische Züge (z. B. TGV, TER); deren Strecke - Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
ausserhalb der Schweiz ist nur Luftlinie, weil der OSM-Ausschnitt nur die Schweiz abdeckt. 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.
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie - Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
für ein paar Sekunden minimal ab. für ein paar Sekunden minimal ab.
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen. - Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
+32 -5
View File
@@ -1,5 +1,7 @@
"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)""" """Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)"""
import csv
import datetime as dt import datetime as dt
import io
import json import json
import sys import sys
import tempfile import tempfile
@@ -36,14 +38,29 @@ SHAPE = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence,shape_dist_travele
DAY = dt.date(2026, 10, 21) 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) tmp.mkdir(parents=True, exist_ok=True)
zp = tmp / "in.zip" zp = tmp / "in.zip"
with zipfile.ZipFile(zp, "w") as z: with zipfile.ZipFile(zp, "w") as z:
for n, c in FILES.items(): for n, c in files.items():
z.writestr(n, "" + c) # wie die echten Schweizer Dateien: mit BOM z.writestr(n, "\ufeff" + c) # wie die echten Schweizer Dateien: mit BOM
meta = prepare.filter_rail(zp, tmp / "rail") meta = prepare.filter_rail(zp, tmp / "rail", "20261020")
assert meta["feed_version"] == "20261017" and meta["trips"] == 1 and meta["stop_times"] == 3, meta assert meta["feed_version"] == ("20261017" if files is FILES else "20261020"), meta
assert meta["trips"] == 1 and meta["stop_times"] == 3, meta
if shapes: if shapes:
(tmp / "rail" / "shapes.txt").write_text(SHAPE) (tmp / "rail" / "shapes.txt").write_text(SHAPE)
prepare.pack_shapes(tmp / "rail") prepare.pack_shapes(tmp / "rail")
@@ -127,6 +144,16 @@ def test_all():
g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie
assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0] assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0]
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.border import SHOW, in_switzerland
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)
assert SHOW["ch"](8.54, 47.378) and not SHOW["de"](8.54, 47.378)
print("alle Tests ok") print("alle Tests ok")
+27
View File
@@ -0,0 +1,27 @@
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen
und Campione als Löcher). Damit zeigt jeder Fahrplan seine Züge nur im eigenen Land, sonst wären
grenzüberschreitende Züge doppelt auf der Karte."""
import json
from pathlib import Path
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text())
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]),
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"]))
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 in_switzerland(lon: float, lat: float) -> bool:
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]):
return False
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)}
File diff suppressed because one or more lines are too long
+27 -8
View File
@@ -2,17 +2,36 @@ import os
from pathlib import Path from pathlib import Path
DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse")) 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" TZ = "Europe/Zurich"
# Schweiz: offizieller GTFS-Fahrplan + GTFS-RT (Trip Updates) von opentransportdata.swiss # Datenquellen. Jede hat einen GTFS-Fahrplan und einen passenden GTFS-RT-Feed (Trip Updates).
GTFS_URL = os.environ.get("TRAINPULSE_GTFS_URL", "") # leer = automatisch das aktuelle Fahrplanjahr SOURCES = {
RT_URL = os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt") "ch": { # opentransportdata.swiss; GTFS-URL leer = automatisch das aktuelle Fahrplanjahr
RT_KEY = os.environ.get("TRAINPULSE_RT_KEY", "") "gtfs_url": os.environ.get("TRAINPULSE_GTFS_URL", ""),
RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window) "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
},
}
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de").split(",") if s in SOURCES]
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf") # 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",
])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle") PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles") PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "5.8,45.7,10.6,47.9" # Schweiz mit etwas Rand TILES_BBOX = "5.5,45.7,15.3,55.2" # Schweiz + Deutschland mit etwas Rand
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)" USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+8 -5
View File
@@ -111,6 +111,9 @@ class Feed:
self.st_dep.append(d) self.st_dep.append(d)
self.st_dist.append(m) self.st_dist.append(m)
self.off[n] = len(self.st_stop) 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 self.shape_index, self.shape_data = {}, None
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size: if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
@@ -131,12 +134,12 @@ class Feed:
cd = path / "calendar_dates.txt" cd = path / "calendar_dates.txt"
if cd.exists(): if cd.exists():
with open(cd, encoding="utf-8-sig") as f: 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: for line in f:
sid, date, typ = line.rstrip("\r\n").rsplit(",", 2) v = line.rstrip("\r\n").replace('"', "").split(",") # kurze Datei ohne Kommas in Werten
date = date.strip('"') if v[di] in window:
if date in window: self.exceptions.setdefault(v[di], {})[v[si]] = int(v[ti_])
self.exceptions.setdefault(date, {})[sid.strip('"')] = int(typ.strip('"'))
self._day_cache = {} self._day_cache = {}
self._geom_cache = {} self._geom_cache = {}
+134 -62
View File
@@ -1,9 +1,10 @@
"""Fahrplan-Update: holt den Schweizer GTFS-Fahrplan, filtert auf Bahn und rechnet mit pfaedle """Fahrplan-Update: holt für jede Quelle (Schweiz, Deutschland) den GTFS-Fahrplan, filtert auf Bahn
die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich per systemd-Timer und und rechnet mit pfaedle die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich
macht nichts, solange opentransportdata.swiss keinen neuen Fahrplan veröffentlicht hat. 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 # alle Quellen
python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen) 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 argparse
import csv import csv
@@ -39,10 +40,12 @@ def timetable_year(today: dt.date) -> int:
return today.year return today.year
def gtfs_url() -> str: def gtfs_url(src: str) -> str:
url = C.SOURCES[src]["gtfs_url"]
if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge # shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
return C.GTFS_URL or ( url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink") return url
def download(url: str, dest: Path) -> None: def download(url: str, dest: Path) -> None:
@@ -56,6 +59,19 @@ def download(url: str, dest: Path) -> None:
tmp.rename(dest) 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)."""
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:
raise RuntimeError(f"{url}: kein Last-Modified-Header")
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: def latest_gtfs_name(url: str) -> str:
# Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht, # 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. # darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt.
@@ -83,26 +99,33 @@ def write_csv(path: Path, header, data) -> int:
return n return n
def filter_by_first_col(z: zipfile.ZipFile, name: str, keys: set, out: Path, collect: str | None = None): def filter_by_col(z: zipfile.ZipFile, name: str, col: 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 """Filter für die Riesendateien (stop_times.txt hat mehrere GB): behält Zeilen, deren Spalte `col`
als Rohtext, schreibt Treffer unverändert weg. Gibt (Anzahl, Werte der Spalte `collect`) zurück.""" 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() n, seen = 0, set()
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f: with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="") lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
header = next(csv.reader([lines.readline()])) header = next(csv.reader([lines.readline()]))
assert header[0] in ("trip_id", "service_id"), header
f.write(",".join(header) + "\n") 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: for line in lines:
if line.split(",", 1)[0].strip('"') in keys: 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) f.write(line)
n += 1 n += 1
if ci is not None: if ci is not None:
seen.add(next(csv.reader([line]))[ci]) seen.add(row[ci])
return n, seen return n, seen
def filter_rail(zip_path: Path, out: Path) -> dict: def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True) out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path) z = zipfile.ZipFile(zip_path)
@@ -119,20 +142,21 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
if "frequencies.txt" in z.namelist(): if "frequencies.txt" in z.namelist():
fh, frd = rows(z, "frequencies.txt") 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: if n:
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", 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") h, rd = rows(z, "stops.txt")
sid, par = h.index("stop_id"), h.index("parent_station")
stops = list(rd) stops = list(rd)
parents = {r[h.index("parent_station")] for r in stops if r[0] in used_stops} parents = {r[par] for r in stops if r[sid] 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)) 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"): for name in ("calendar.txt", "calendar_dates.txt"):
if name in z.namelist(): 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"): for name in ("agency.txt", "feed_info.txt"):
if name in z.namelist(): if name in z.namelist():
h, rd = rows(z, name) h, rd = rows(z, name)
@@ -140,17 +164,40 @@ 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([])) h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
info = dict(zip(h, next(rd, []))) 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} "routes": len(routes), "trips": len(trips), "stop_times": n_st}
def ensure_osm() -> Path: def ensure_osm() -> Path:
osm = C.DATA_DIR / "osm" / "region.osm.pbf" """Schienennetz aus OpenStreetMap für alle Länder: herunterladen, mit osmium auf Bahn-Objekte
if not osm.exists() or time.time() - osm.stat().st_mtime > 30 * 86400: filtern (Deutschland: ~4 GB -> wenige 100 MB) und zusammenführen. Monatlich erneuert."""
osm.parent.mkdir(parents=True, exist_ok=True) d = C.DATA_DIR / "osm"
log.info("Lade OSM-Daten %s", C.OSM_URL) rail, urls_file = d / "rail.osm.pbf", d / "urls.txt"
download(C.OSM_URL, osm) wanted = "\n".join(C.OSM_URLS)
return osm 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))
tmp = d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True)
tmp.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) -> Path | None:
@@ -202,9 +249,12 @@ def pack_shapes(d: Path) -> None:
def ensure_tiles() -> None: def ensure_tiles() -> None:
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen.""" """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" dest = C.DATA_DIR / "tiles" / "region.pmtiles"
if dest.exists() and dest.stat().st_size: 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 return
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp") tmp = dest.with_suffix(".tmp")
@@ -212,55 +262,48 @@ def ensure_tiles() -> None:
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d") d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d) log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp), r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", "--maxzoom=15"]) f"--bbox={C.TILES_BBOX}", f"--maxzoom={C.TILES_MAXZOOM}"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size: if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest) tmp.rename(dest)
info.write_text(wanted)
return return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund") log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(src: Path, meta: dict) -> None: def publish(source: str, src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta)) (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(): if dest.exists():
shutil.rmtree(dest) shutil.rmtree(dest)
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner 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) shutil.rmtree(old)
def main() -> None: def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool = False) -> 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)
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 {}
work = C.DATA_DIR / "work" work = C.DATA_DIR / "work"
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
work.mkdir() work.mkdir()
if args.zip: version = None
zip_path, name = args.zip, args.zip.name if zip_path:
name = zip_path.name
else: else:
url = gtfs_url() url = gtfs_url(source)
name = latest_gtfs_name(url) name, version = remote_id(source, url)
if name == state.get("last_file") and not args.force: if name == state.get(source) and not force:
log.info("Kein neuer Fahrplan (%s)", name) log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return return
zip_path = work / name zip_path = work / "gtfs.zip"
log.info("Lade %s", name) log.info("[%s] Lade Fahrplan %s", source, name)
download(url, zip_path) download(url, zip_path)
t = time.time() t = time.time()
meta = filter_rail(zip_path, work / "rail") meta = filter_rail(zip_path, work / "rail", version)
log.info("Bahn-Filter: %s in %.0f s", meta, time.time() - t) log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if not args.zip: if zip_path.parent == work:
zip_path.unlink() zip_path.unlink()
shaped = run_pfaedle(work, work / "rail") shaped = run_pfaedle(work, work / "rail")
@@ -269,12 +312,41 @@ def main() -> None:
pack_shapes(shaped) pack_shapes(shaped)
meta["source_file"] = name meta["source_file"] = name
meta["prepared_at"] = int(time.time()) meta["prepared_at"] = int(time.time())
publish(shaped or work / "rail", meta) publish(source, shaped or work / "rail", meta)
state["last_file"] = name
state_file.write_text(json.dumps(state))
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
log.info("Fahrplan %s bereit", meta["feed_version"]) state[source] = name
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")
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(): # Ordner der alten Version (feeds/<version>) und abgeschaltete Quellen
if p.is_dir() and p.name not in C.SOURCES:
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__": if __name__ == "__main__":
+5 -3
View File
@@ -34,8 +34,10 @@ def parse(data: bytes, known_trips: dict):
return version, out return version, out
def fetch() -> bytes: def fetch(url: str, key: str = "") -> bytes:
r = httpx.get(C.RT_URL, follow_redirects=True, timeout=30, headers={ headers = {"User-Agent": C.USER_AGENT}
"Authorization": f"Bearer {C.RT_KEY}", "User-Agent": C.USER_AGENT}) if key:
headers["Authorization"] = f"Bearer {key}"
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
r.raise_for_status() r.raise_for_status()
return r.content return r.content
+89 -52
View File
@@ -15,7 +15,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import config as C from . import config as C
from . import realtime from . import border, realtime
from .feed import TZ, Feed from .feed import TZ, Feed
log = logging.getLogger("trainpulse") log = logging.getLogger("trainpulse")
@@ -23,13 +23,25 @@ STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Bro
WEB = Path(__file__).resolve().parent.parent / "web" 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
@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: class State:
feed: Feed | None = None sources = {name: Source(name) for name in C.ENABLED}
rt: dict = {}
rt_version: str | None = None
rt_at: float | None = None
rt_error: str | None = None
rt_tried_at: float | None = None
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}' vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
vehicles_gz: bytes = gzip.compress(vehicles) vehicles_gz: bytes = gzip.compress(vehicles)
count = 0 count = 0
@@ -39,62 +51,78 @@ class State:
S = State() S = State()
def loop(every: float, fn): def loop(every: float, fn, *args):
def run(): def run():
while True: while True:
t = time.time() t = time.time()
try: try:
fn() fn(*args)
except Exception: 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))) time.sleep(max(1.0, every - (time.time() - t)))
threading.Thread(target=run, daemon=True, name=fn.__name__).start() threading.Thread(target=run, daemon=True, name=fn.__name__).start()
def watch_feeds(): def watch_feeds():
"""Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten """Lädt je Quelle den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (Schweiz:
erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten.""" 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]
today = dt.datetime.now(TZ).date() today = dt.datetime.now(TZ).date()
if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)): for src in S.sources.values():
return ready = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
log.info("Lade Fahrplan %s …", want) if not ready:
continue
want = src.rt_version if src.rt_version in ready else ready[-1]
if src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1)):
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time() t = time.time()
S.feed = Feed(C.FEEDS_DIR / want) src.feed = Feed(C.FEEDS_DIR / src.name / want)
log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids)) 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 try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime() poll_realtime(src)
except Exception: except Exception:
log.exception("Erster Echtzeit-Abruf fehlgeschlagen") log.exception("[%s] Erster Echtzeit-Abruf fehlgeschlagen", src.name)
compute_positions() compute_positions()
def poll_realtime(): def poll_realtime(src: Source):
if not C.RT_KEY or not S.feed: if not src.has_rt or not src.feed:
return return
try: try:
S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index) src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]),
S.rt_at, S.rt_error = time.time(), None src.feed.trip_index)
src.rt_at, src.rt_error = time.time(), None
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
S.rt_error = str(e) src.rt_error = str(e)
if S.rt_at and time.time() - S.rt_at > 600: if src.rt_at and time.time() - src.rt_at > 600:
S.rt = {} src.rt = {}
raise raise
finally: finally:
S.rt_tried_at = time.time() src.rt_tried_at = time.time()
def positions(src: Source, t: float) -> list:
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
feats = []
for f in src.feed.vehicles(t, src.rt):
if show and not show(*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(): def compute_positions():
if not S.feed:
return
now = time.time() now = time.time()
feats = S.feed.vehicles(now, S.rt) feats, future = [], [{} for _ in range(STEPS)]
future = [{f["properties"]["id"]: f["geometry"]["coordinates"] + [f["properties"]["heading"]] for src in list(S.sources.values()):
for f in S.feed.vehicles(now + STEP * k, S.rt)} if not src.feed:
for k in range(1, STEPS + 1)] continue
feats += positions(src, now)
for k in range(STEPS):
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)})
for f in feats: for f in feats:
path = [] path = []
for pos in future: for pos in future:
@@ -114,7 +142,8 @@ async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True) C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
loop(60, watch_feeds) 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) loop(3, compute_positions)
yield yield
@@ -130,28 +159,36 @@ def vehicles(request: Request):
return Response(S.vehicles, media_type="application/json", headers=headers) return Response(S.vehicles, media_type="application/json", headers=headers)
@app.get("/api/trip/{date}/{ti}") @app.get("/api/trip/{source}/{date}/{ti}")
def trip(date: str, ti: int): def trip(source: str, date: str, ti: int):
f = S.feed 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()): if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
raise HTTPException(404) 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") @app.get("/api/status")
def status(): def status():
f = S.feed loaded = [s for s in S.sources.values() if s.feed]
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 { return {
"feed_version": f.version if f else None, "loaded": bool(loaded),
"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),
"vehicles": S.count,
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind # erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
"ready": bool(f and S.computed_at and (not C.RT_KEY or (S.rt_tried_at and S.computed_at >= S.rt_tried_at))), "ready": bool(loaded and S.computed_at and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"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()},
} }
+8 -6
View File
@@ -25,8 +25,8 @@ const delayColor = ["case",
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: "map", container: "map",
center: [8.23, 46.82], center: [9.6, 49.2], // Deutschland + Schweiz
zoom: 7.4, zoom: 5.6,
hash: true, hash: true,
style: { style: {
version: 8, version: 8,
@@ -50,7 +50,8 @@ const map = new maplibregl.Map({
route: { type: "geojson", data: EMPTY }, route: { type: "geojson", data: EMPTY },
trains: { trains: {
type: "geojson", data: EMPTY, type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>', attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0)',
}, },
}, },
layers: [ layers: [
@@ -234,7 +235,7 @@ async function refreshStatus() {
try { try {
const s = await (await fetch("api/status")).json(); const s = await (await fetch("api/status")).json();
if (!s.ready) { if (!s.ready) {
loading.textContent = s.feed_version ? "Züge werden geladen …" loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 15–20 Minuten."; : "Fahrplan wird vorbereitet … beim ersten Start dauert das 15–20 Minuten.";
loading.hidden = false; loading.hidden = false;
return; return;
@@ -244,8 +245,9 @@ async function refreshStatus() {
await refreshTrains(); await refreshTrains();
loading.hidden = true; loading.hidden = true;
} }
const rtOk = s.realtime && !s.rt_error && s.rt_age !== null && s.rt_age < 180; const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : " · Verspätungen zurzeit nicht verfügbar"); el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn"; el.className = rtOk ? "" : "warn";
} catch (e) { } catch (e) {
loading.textContent = "Keine Verbindung zum Server …"; loading.textContent = "Keine Verbindung zum Server …";