Trams und U-Bahnen (CH, DE, NL): eigener Filter, ab Stadtzoom im Ausschnitt; NL-Echtzeit auch für Tram/Metro; Vorausschau ohne 7-fache Neuberechnung; deutsche Steig-Zusätze aus Haltestellennamen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-22 10:33:45 +02:00
parent 49860c50f5
commit d11cc109f6
9 changed files with 148 additions and 56 deletions
+10 -5
View File
@@ -1,6 +1,7 @@
# TrainPulse # TrainPulse
Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien und den Niederlanden: Position entlang der echten Strecke, Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien und den Niederlanden, dazu Trams und U-Bahnen
(CH, DE, NL): Position entlang der echten Strecke,
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte, Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2). dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
@@ -9,7 +10,7 @@ dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der
``` ```
Fahrpläne (GTFS) ─────────────────────────┐ Fahrpläne (GTFS) ─────────────────────────┐
CH opentransportdata.swiss, DE gtfs.de, ├▶ updater-Container (stündlich) CH opentransportdata.swiss, DE gtfs.de, ├▶ updater-Container (stündlich)
FR SNCF, NL OVapi, BE NMBS/SNCB │ ├─ Filter: nur Bahn (route_type 2, 100–199) FR SNCF, NL OVapi, BE NMBS/SNCB │ ├─ Filter: Bahn, Tram, U-Bahn (ohne Bus/Schiff)
OpenStreetMap (Geofabrik, 5 Länder) ──────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten OpenStreetMap (Geofabrik, 5 Länder) ──────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten
├─ pfaedle: Streckenverläufe ├─ pfaedle: Streckenverläufe
└─▶ data/feeds/<ch|de|fr|nl|be>/<version>/ └─▶ data/feeds/<ch|de|fr|nl|be>/<version>/
@@ -38,7 +39,7 @@ die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*): 1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim
Aufbereiten bis ~3 GB, der Server ~1 GB), **50 GB Disk** (Kartenkacheln ~9,4 GB, beim Aufbereiten bis ~2,5 GB, der Server ~2,5 GB), **50 GB Disk** (Kartenkacheln ~9,4 GB, beim
monatlichen OSM-Update kurzzeitig bis ~6 GB Download für Frankreich), Netz per DHCP. monatlichen OSM-Update kurzzeitig bis ~6 GB Download für Frankreich), Netz per DHCP.
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
im LXC nicht. im LXC nicht.
@@ -89,13 +90,17 @@ Die Hinweise erscheinen unten rechts auf der Karte.
## Bekannte Grenzen ## Bekannte Grenzen
- Nur Bahn. Busse, Trams und U-Bahnen sind im Fahrplan, werden aber noch herausgefiltert. - 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 und NL. Belgien (Brüssel STIB/MIVB) und Frankreich (RATP, TCL …)
haben eigene Datensätze, die noch fehlen. 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) - Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
werden nicht angezeigt, sobald sie die Schweiz verlassen. werden nicht angezeigt, sobald sie die Schweiz verlassen.
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt - Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
wird als Ziel der letzte Halt. wird als Ziel der letzte Halt.
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das - Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
rund 9 GB Datenverkehr pro Tag; die anderen Länder zusammen ~4 GB. rund 9 GB Datenverkehr pro Tag; die Niederlande (Züge + Trams/Metro, ~6 MB/min) ~8 GB, die übrigen ~4 GB.
- Belgien: die Feed-Adressen unter sncb-opendata.hafas.de sind nicht offiziell dokumentiert und - 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 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. `TRAINPULSE_BE_RT_URL` (und ggf. `TRAINPULSE_BE_RT_KEY`) in `.env` eintragen.
+10 -1
View File
@@ -123,6 +123,10 @@ def test_all():
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 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 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 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 # Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben
from trainpulse.feed import with_min_dwell from trainpulse.feed import with_min_dwell
@@ -192,10 +196,15 @@ def test_all():
assert len(v) == 1 and v[0]["properties"]["to"] == "Gamma" and v[0]["properties"]["nr"] == "" 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 assert de.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates
from trainpulse.feed import long_distance from trainpulse.feed import STOP_SUFFIX, long_distance, route_mode
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 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("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3")
assert not long_distance("TER", "K1") and not long_distance("", "") 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("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "")
from trainpulse.feed import LINE_PREFIX from trainpulse.feed import LINE_PREFIX
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct" assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
+3 -2
View File
@@ -28,9 +28,10 @@ SOURCES = {
"needs_key": False, "needs_key": False,
"rt_interval": 60, "rt_interval": 60,
}, },
"nl": { # OVapi / Stichting OpenGeo; Zug-Feed statt des grossen Gesamt-Feeds "nl": { # OVapi / Stichting OpenGeo; Züge und Trams/Metro stehen in zwei getrennten Feeds
"gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip", "gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip",
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb", # mehrere Echtzeit-Adressen durch Leerzeichen getrennt
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb https://gtfs.ovapi.nl/nl/tripUpdates.pb",
"rt_key": "", "rt_key": "",
"needs_key": False, "needs_key": False,
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute "rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
+30 -7
View File
@@ -68,6 +68,20 @@ FR_TRIP = re.compile(r"_F:([A-Z]+):")
# Niederlande (OVapi): lange Gattungsnamen kürzen, damit die Beschriftung auf der Karte passt # 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_ALIAS = {"Intercity": "IC", "Intercity direct": "ICD", "Sprinter": "SPR", "Nightjet": "NJ", "Eurocity": "EC"}
LINE_PREFIX = re.compile(r"^(Stoptrein|Sneltrein) (?=\S)") # "Stoptrein RS4" -> "RS4" 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
def long_distance(cat: str, line: str) -> bool: def long_distance(cat: str, line: str) -> bool:
@@ -131,7 +145,7 @@ class Feed:
parent = r[h["parent_station"]] if "parent_station" in h else "" 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_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_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_lat.append(float(r[h["stop_lat"]]))
self.stop_lon.append(float(r[h["stop_lon"]])) self.stop_lon.append(float(r[h["stop_lon"]]))
self.stop_platform.append(r[h["platform_code"]] if "platform_code" in h else "") self.stop_platform.append(r[h["platform_code"]] if "platform_code" in h else "")
@@ -144,6 +158,7 @@ class Feed:
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line), "line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line),
"cat": r[h["route_desc"]] if "route_desc" in h else "", "cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" 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",
} }
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], [] self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
@@ -164,7 +179,7 @@ class Feed:
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], "" self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
for route in self.routes.values(): for route in self.routes.values():
route["line"] = route["line"] or route["cat"] route["line"] = route["line"] or route["cat"]
route["fv"] = long_distance(route["cat"], route["line"]) route["fv"] = route["mode"] == "rail" and long_distance(route["cat"], route["line"])
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i # Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route) n = len(self.trip_route)
@@ -344,7 +359,9 @@ class Feed:
# ---------- Positionen ---------- # ---------- 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 = [] feats = []
today = dt.datetime.fromtimestamp(now, TZ).date() today = dt.datetime.fromtimestamp(now, TZ).date()
for d in (today - dt.timedelta(days=1), today): for d in (today - dt.timedelta(days=1), today):
@@ -365,15 +382,20 @@ class Feed:
if not live or not (dep[live[0]] <= t <= arr[live[-1]]): if not live or not (dep[live[0]] <= t <= arr[live[-1]]):
continue continue
pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt
i = min(bisect_right(pd, t), len(pd) - 1) # Zug ist zwischen Halt i-1 und i (oder steht an Halt i)
g = self.geometry(ti) g = self.geometry(ti)
sd = g[2] sd = g[2]
o = self.off[ti] o = self.off[ti]
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 if t >= pa[i]: # steht am Halt i
dist = sd[i]
delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i] delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i]
else: else:
dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], t - pd[i - 1])
delay = arr[i] - self.st_arr[o + i] delay = arr[i] - self.st_arr[o + i]
lon, lat = self.point_at(g, dist) lon, lat = self.point_at(g, dist)
# Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand # Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand
@@ -385,12 +407,13 @@ class Feed:
"type": "Feature", "type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]}, "geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": { "properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"], "id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"], "mode": route["mode"],
"nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti], "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]], "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading, "heading": heading,
}, },
"_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt "_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 return feats
+8 -7
View File
@@ -23,13 +23,14 @@ import httpx
from . import border from . import border
from . import config as C from . import config as C
from .feed import route_mode
log = logging.getLogger("prepare") log = logging.getLogger("prepare")
def is_rail(route_type: str) -> bool: # Aufbereitungs-Version: ändert sich, was aus dem Fahrplan übernommen wird, rechnet der Updater
t = int(route_type) # alle Fahrpläne einmal neu, auch ohne neuen Fahrplan (2: mit Trams und U-Bahnen)
return t == 2 or 100 <= t <= 199 # GTFS + "extended route types" (Schweiz nutzt 100–117) FORMAT = 2
def timetable_year(today: dt.date) -> int: def timetable_year(today: dt.date) -> int:
@@ -136,7 +137,7 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
z = zipfile.ZipFile(zip_path) z = zipfile.ZipFile(zip_path)
h, rd = rows(z, "routes.txt") 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) write_csv(out / "routes.txt", h, routes)
route_ids = {r[h.index("route_id")] for r in routes} route_ids = {r[h.index("route_id")] for r in routes}
@@ -226,7 +227,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
"--overwrite"], check=True) "--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source) log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time() 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() osm.unlink()
out = work / "gtfs-out" out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists(): if res.returncode != 0 or not (out / "shapes.txt").exists():
@@ -313,7 +314,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
else: else:
url = gtfs_url(source) url = gtfs_url(source)
name, version = remote_id(source, url) name, version = remote_id(source, url)
if name == state.get(source) and not force: if f"{name} #{FORMAT}" == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (%s)", source, name) log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return return
zip_path = work / "gtfs.zip" zip_path = work / "gtfs.zip"
@@ -334,7 +335,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
meta["prepared_at"] = int(time.time()) meta["prepared_at"] = int(time.time())
publish(source, shaped or work / "rail", meta) publish(source, shaped or work / "rail", meta)
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
state[source] = name state[source] = f"{name} #{FORMAT}"
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"]) log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
+36 -18
View File
@@ -47,6 +47,9 @@ class State:
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
# 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 computed_at: float | None = None
@@ -99,8 +102,12 @@ def poll_realtime(src: Source):
if not src.has_rt or not src.feed: if not src.has_rt or not src.feed:
return return
try: try:
src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]), merged, version = {}, None
src.feed.trip_index) 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 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
src.rt_error = str(e) src.rt_error = str(e)
@@ -111,9 +118,9 @@ def poll_realtime(src: Source):
src.rt_tried_at = time.time() src.rt_tried_at = time.time()
def positions(src: Source, t: float) -> list: def positions(src: Source, t: float, ahead: tuple = ()) -> list:
feats = [] feats = []
for f in src.feed.vehicles(t, src.rt): for f in src.feed.vehicles(t, src.rt, ahead):
if len(S.sources) > 1 and not border.shows(src.name, S.sources, *f["geometry"]["coordinates"]): if len(S.sources) > 1 and not border.shows(src.name, S.sources, *f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}" f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
@@ -127,18 +134,10 @@ def compute_positions():
for src in list(S.sources.values()): for src in list(S.sources.values()):
if not src.feed: if not src.feed:
continue continue
cur = positions(src, now) # Distanz entlang der Strecke jetzt und alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
# Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt cur = positions(src, now, tuple(STEP * (k + 1) for k in range(STEPS)))
future = [{f["properties"]["id"]: f["_dist"] for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)}
for k in range(STEPS)] # Schlüssel Betriebstag/Fahrt: Nachtzüge laufen über Mitternacht
for f in cur: for f in cur:
key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]] f["path"] = src.feed.track(f.pop("_ti"), [f.pop("_dist"), *f.pop("_ahead")], STEP)
for pos in future:
if key not in pos:
break # Fahrt endet
dists.append(pos[key])
f["path"] = src.feed.track(f.pop("_ti"), dists, STEP)
del f["_dist"]
feats += cur feats += cur
# Im übrigen Ausland (Luxemburg, Österreich, …) zeigen alle Fahrpläne ihre Züge -> dort Doppelte # 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 # (gleiche Linie, gleiche ~500-m-Zelle) entfernen, bevorzugt den mit Echtzeit behalten
@@ -151,11 +150,13 @@ def compute_positions():
continue continue
seen.add((line, cx, cy)) seen.add((line, cx, cy))
out.append(f) out.append(f)
feats = out rail = [f for f in out if f["properties"]["mode"] == "rail"]
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats}, 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() separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5) 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 @asynccontextmanager
@@ -190,6 +191,22 @@ 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/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}") @app.get("/api/trip/{source}/{date}/{ti}")
def trip(source: str, date: str, ti: int): def trip(source: str, date: str, ti: int):
src = S.sources.get(source) src = S.sources.get(source)
@@ -211,6 +228,7 @@ def status():
"ready": bool(loaded and not pending and S.computed_at "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)), 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, "vehicles": S.count,
"urban": len(S.urban[1]),
"rt_ok": bool(ok) and len(ok) == len(loaded), "rt_ok": bool(ok) and len(ok) == len(loaded),
"rt_some": bool(ok), "rt_some": bool(ok),
"sources": {s.name: { "sources": {s.name: {
+47 -15
View File
@@ -77,7 +77,10 @@ const map = new maplibregl.Map({
"icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"], "icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0], "icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map", "icon-rotation-alignment": "map",
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.3, 9, 0.5, 12, 0.9, 14, 1.3], // 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, "icon-allow-overlap": true, "icon-ignore-placement": true,
}, },
paint: { paint: {
@@ -119,13 +122,15 @@ function setMode(mode) {
} }
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m); for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Filter: alle / Nahverkehr / Fernverkehr ---------- // ---------- Filter: alle / Nahverkehr / Fernverkehr / Tram & U-Bahn ----------
const FILTERS = { all: null, local: ["!", ["get", "fv"]], long: ["get", "fv"] }; const RAIL = ["==", ["get", "mode"], "rail"];
const FILTERS = { all: null, local: ["all", RAIL, ["!", ["get", "fv"]]], long: ["get", "fv"], city: ["!", RAIL] };
function setFilter(name) { function setFilter(name) {
if (!(name in FILTERS)) name = "all"; if (!(name in FILTERS)) name = "all";
for (const id of ["trains", "train-labels"]) map.setFilter(id, FILTERS[name]); for (const id of ["trains", "train-labels"]) map.setFilter(id, FILTERS[name]);
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).setAttribute("aria-pressed", String(n === name)); for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).setAttribute("aria-pressed", String(n === name));
updateHint();
try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ } try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ }
} }
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclick = () => setFilter(n); for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclick = () => setFilter(n);
@@ -135,20 +140,48 @@ for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclic
// Server liefert je Zug die Gleispunkte der nächsten ~10 s, jeweils mit der Zeit, zu der der Zug dort ist; // 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. // der Browser fährt sie ab, die Spitze dreht sich weich in die Richtung des aktuellen Gleisstücks.
let trains = { type: "FeatureCollection", features: [] }; let trains = { type: "FeatureCollection", features: [] };
let ts = 0, 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 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() {
const city = document.getElementById("flt-city").getAttribute("aria-pressed") === "true";
document.getElementById("hint").hidden = !(city && 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() { async function refreshTrains() {
if (document.hidden || !ready) return; if (document.hidden || !ready) return;
try { try {
const r = await fetch("api/vehicles"); let urban = [];
if (!r.ok) return; if (map.getZoom() >= URBAN_Z) {
const now = Number(r.headers.get("X-Now")); const b = map.getBounds(), dx = (b.getEast() - b.getWest()) * 0.3, dy = (b.getNorth() - b.getSouth()) * 0.3;
const d = await r.json(); urbanBox = [b.getWest() - dx, b.getSouth() - dy, b.getEast() + dx, b.getNorth() + dy];
if (now) clockOffset = Date.now() / 1000 - now; 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])); const old = new Map(trains.features.map((f) => [f.properties.id, f]));
for (const f of d.features) { 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] 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); const o = old.get(f.properties.id);
if (o) { if (o) {
@@ -157,8 +190,7 @@ async function refreshTrains() {
f.shownD = o.shownD; f.shownD = o.shownD;
} }
} }
trains = d; trains = { type: "FeatureCollection", features };
ts = d.ts;
render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild
} catch (e) { /* nächster Versuch in ein paar Sekunden */ } } catch (e) { /* nächster Versuch in ein paar Sekunden */ }
} }
@@ -230,12 +262,12 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
let lastRender = 0; let lastRender = 0;
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
function render() { function render() {
const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server const now = Date.now() / 1000 - clockOffset;
const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen
lastRender = performance.now(); lastRender = performance.now();
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
for (const f of trains.features) { for (const f of trains.features) {
const p = f.pts; const p = f.pts, t = now - f.t0; // s seit Berechnung auf dem Server
if (!p) continue; if (!p) continue;
// Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren // Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
let i = 0; let i = 0;
@@ -286,7 +318,7 @@ async function refreshStatus() {
} }
loading.hidden = true; // auch nach "Keine Verbindung" wieder weg loading.hidden = true; // auch nach "Keine Verbindung" wieder weg
const rtOk = s.rt_ok; const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some 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"); ? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn"; el.className = rtOk ? "" : "warn";
} catch (e) { } catch (e) {
+2
View File
@@ -25,6 +25,7 @@
<button id="flt-all" aria-pressed="true">Alle</button> <button id="flt-all" aria-pressed="true">Alle</button>
<button id="flt-local" aria-pressed="false">Nahverkehr</button> <button id="flt-local" aria-pressed="false">Nahverkehr</button>
<button id="flt-long" aria-pressed="false">Fernverkehr</button> <button id="flt-long" aria-pressed="false">Fernverkehr</button>
<button id="flt-city" aria-pressed="false" title="Trams und U-Bahnen">Tram/U</button>
</div> </div>
<div id="search"> <div id="search">
<input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off" <input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off"
@@ -39,6 +40,7 @@
<li><i style="--c:var(--na)"></i>ohne Echtzeit</li> <li><i style="--c:var(--na)"></i>ohne Echtzeit</li>
</ul> </ul>
<p id="status"></p> <p id="status"></p>
<p id="hint" hidden>Trams und U-Bahnen erscheinen beim Hineinzoomen.</p>
</header> </header>
<div class="card" id="loading" role="status">Züge werden geladen …</div> <div class="card" id="loading" role="status">Züge werden geladen …</div>
+2 -1
View File
@@ -20,7 +20,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
.seg + .seg { margin-top: 6px; } .seg + .seg { margin-top: 6px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .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 button { border: 0; background: transparent; color: var(--fg); padding: 6px 11px; font: inherit; cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } .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); } .legend { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--muted); }
@@ -36,6 +36,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
#results .r2 { font-size: 12px; color: var(--muted); } #results .r2 { font-size: 12px; color: var(--muted); }
#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); } #status { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
#status.warn { color: var(--d2); } #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; } #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; } #close { position: sticky; top: 0; float: right; border: 0; background: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; }