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:
@@ -1,6 +1,7 @@
|
||||
# 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,
|
||||
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) ─────────────────────────┐
|
||||
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
|
||||
├─ pfaedle: Streckenverläufe
|
||||
└─▶ 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*):
|
||||
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.
|
||||
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
|
||||
im LXC nicht.
|
||||
@@ -89,13 +90,17 @@ Die Hinweise erscheinen unten rechts auf der Karte.
|
||||
|
||||
## 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)
|
||||
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 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
|
||||
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.
|
||||
|
||||
+10
-1
@@ -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 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
|
||||
@@ -192,10 +196,15 @@ def test_all():
|
||||
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 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 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", "")
|
||||
from trainpulse.feed import LINE_PREFIX
|
||||
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
|
||||
|
||||
@@ -28,9 +28,10 @@ SOURCES = {
|
||||
"needs_key": False,
|
||||
"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",
|
||||
"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": "",
|
||||
"needs_key": False,
|
||||
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
|
||||
|
||||
+30
-7
@@ -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
|
||||
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
|
||||
|
||||
|
||||
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 ""
|
||||
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 "")
|
||||
@@ -144,6 +158,7 @@ class Feed:
|
||||
"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",
|
||||
}
|
||||
|
||||
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], ""
|
||||
for route in self.routes.values():
|
||||
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
|
||||
n = len(self.trip_route)
|
||||
@@ -344,7 +359,9 @@ class Feed:
|
||||
|
||||
# ---------- 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):
|
||||
@@ -365,15 +382,20 @@ class Feed:
|
||||
if not live or not (dep[live[0]] <= t <= arr[live[-1]]):
|
||||
continue
|
||||
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)
|
||||
sd = g[2]
|
||||
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
|
||||
dist = sd[i]
|
||||
delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i]
|
||||
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]
|
||||
lon, lat = self.point_at(g, dist)
|
||||
# Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand
|
||||
@@ -385,12 +407,13 @@ class Feed:
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"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],
|
||||
"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
|
||||
|
||||
|
||||
@@ -23,13 +23,14 @@ 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:
|
||||
@@ -136,7 +137,7 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
|
||||
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}
|
||||
|
||||
@@ -226,7 +227,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
|
||||
"--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():
|
||||
@@ -313,7 +314,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
|
||||
else:
|
||||
url = gtfs_url(source)
|
||||
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)
|
||||
return
|
||||
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())
|
||||
publish(source, shaped or work / "rail", meta)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
state[source] = name
|
||||
state[source] = f"{name} #{FORMAT}"
|
||||
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
|
||||
|
||||
|
||||
|
||||
+36
-18
@@ -47,6 +47,9 @@ class State:
|
||||
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
|
||||
|
||||
|
||||
@@ -99,8 +102,12 @@ def poll_realtime(src: Source):
|
||||
if not src.has_rt or not src.feed:
|
||||
return
|
||||
try:
|
||||
src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]),
|
||||
src.feed.trip_index)
|
||||
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
|
||||
src.rt_error = str(e)
|
||||
@@ -111,9 +118,9 @@ def poll_realtime(src: Source):
|
||||
src.rt_tried_at = time.time()
|
||||
|
||||
|
||||
def positions(src: Source, t: float) -> list:
|
||||
def positions(src: Source, t: float, ahead: tuple = ()) -> list:
|
||||
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"]):
|
||||
continue # gehört zum Fahrplan des Nachbarlandes
|
||||
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
|
||||
@@ -127,18 +134,10 @@ def compute_positions():
|
||||
for src in list(S.sources.values()):
|
||||
if not src.feed:
|
||||
continue
|
||||
cur = positions(src, now)
|
||||
# Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
|
||||
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
|
||||
# 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:
|
||||
key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]]
|
||||
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"]
|
||||
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
|
||||
@@ -151,11 +150,13 @@ def compute_positions():
|
||||
continue
|
||||
seen.add((line, cx, cy))
|
||||
out.append(f)
|
||||
feats = out
|
||||
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats},
|
||||
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
|
||||
@@ -190,6 +191,22 @@ def vehicles(request: Request):
|
||||
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}")
|
||||
def trip(source: str, date: str, ti: int):
|
||||
src = S.sources.get(source)
|
||||
@@ -211,6 +228,7 @@ def status():
|
||||
"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: {
|
||||
|
||||
+47
-15
@@ -77,7 +77,10 @@ const map = new maplibregl.Map({
|
||||
"icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"],
|
||||
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
|
||||
"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,
|
||||
},
|
||||
paint: {
|
||||
@@ -119,13 +122,15 @@ function setMode(mode) {
|
||||
}
|
||||
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) {
|
||||
if (!(name in FILTERS)) name = "all";
|
||||
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));
|
||||
updateHint();
|
||||
try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ }
|
||||
}
|
||||
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;
|
||||
// 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, 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() {
|
||||
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() {
|
||||
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;
|
||||
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 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]
|
||||
const o = old.get(f.properties.id);
|
||||
if (o) {
|
||||
@@ -157,8 +190,7 @@ async function refreshTrains() {
|
||||
f.shownD = o.shownD;
|
||||
}
|
||||
}
|
||||
trains = d;
|
||||
ts = d.ts;
|
||||
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 */ }
|
||||
}
|
||||
@@ -230,12 +262,12 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
|
||||
let lastRender = 0;
|
||||
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
|
||||
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
|
||||
lastRender = performance.now();
|
||||
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
|
||||
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;
|
||||
// Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
|
||||
let i = 0;
|
||||
@@ -286,7 +318,7 @@ async function refreshStatus() {
|
||||
}
|
||||
loading.hidden = true; // auch nach "Keine Verbindung" wieder weg
|
||||
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");
|
||||
el.className = rtOk ? "" : "warn";
|
||||
} catch (e) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<button id="flt-all" aria-pressed="true">Alle</button>
|
||||
<button id="flt-local" aria-pressed="false">Nahverkehr</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 id="search">
|
||||
<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>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
|
||||
|
||||
.seg + .seg { margin-top: 6px; }
|
||||
.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); }
|
||||
|
||||
.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); }
|
||||
#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; }
|
||||
|
||||
Reference in New Issue
Block a user