From d11cc109f6b10e41b5a3f579d097d512d4d8e870 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:33:45 +0200 Subject: [PATCH] =?UTF-8?q?Trams=20und=20U-Bahnen=20(CH,=20DE,=20NL):=20ei?= =?UTF-8?q?gener=20Filter,=20ab=20Stadtzoom=20im=20Ausschnitt;=20NL-Echtze?= =?UTF-8?q?it=20auch=20f=C3=BCr=20Tram/Metro;=20Vorausschau=20ohne=207-fac?= =?UTF-8?q?he=20Neuberechnung;=20deutsche=20Steig-Zus=C3=A4tze=20aus=20Hal?= =?UTF-8?q?testellennamen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb --- README.md | 15 +++++++---- tests/test_core.py | 11 +++++++- trainpulse/config.py | 5 ++-- trainpulse/feed.py | 37 +++++++++++++++++++++----- trainpulse/prepare.py | 15 ++++++----- trainpulse/server.py | 54 ++++++++++++++++++++++++------------- web/app.js | 62 ++++++++++++++++++++++++++++++++----------- web/index.html | 2 ++ web/style.css | 3 ++- 9 files changed, 148 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index af740e7..2524c7d 100644 --- a/README.md +++ b/README.md @@ -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/// @@ -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. diff --git a/tests/test_core.py b/tests/test_core.py index 64b5629..eb977d0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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" diff --git a/trainpulse/config.py b/trainpulse/config.py index 74b550f..9986d21 100644 --- a/trainpulse/config.py +++ b/trainpulse/config.py @@ -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 diff --git a/trainpulse/feed.py b/trainpulse/feed.py index beca1a0..3c0543e 100644 --- a/trainpulse/feed.py +++ b/trainpulse/feed.py @@ -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 diff --git a/trainpulse/prepare.py b/trainpulse/prepare.py index 5ddeef4..98e9577 100644 --- a/trainpulse/prepare.py +++ b/trainpulse/prepare.py @@ -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"]) diff --git a/trainpulse/server.py b/trainpulse/server.py index 018b350..543517f 100644 --- a/trainpulse/server.py +++ b/trainpulse/server.py @@ -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: { diff --git a/web/app.js b/web/app.js index d3c4249..047cbda 100644 --- a/web/app.js +++ b/web/app.js @@ -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) { diff --git a/web/index.html b/web/index.html index 3c2c585..5eba271 100644 --- a/web/index.html +++ b/web/index.html @@ -25,6 +25,7 @@ +