commit d92dac4a001bc68495e3c0a6561d62fb4cb152aa Author: Guido Date: Wed Oct 21 14:59:08 2026 +0200 TrainPulse: Live-Zugkarte Schweiz (Grundgerüst) Fahrplan-Aufbereitung (Bahnfilter + pfaedle), GTFS-RT-Abruf, Positionsberechnung entlang der Strecke, Weboberfläche mit Karte/Satellit, Proxmox-LXC-Installation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..355805e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# TrainPulse – Schweiz + +Live-Karte aller Züge in der Schweiz: Position entlang der echten Strecke, Linie, Zugnummer, +Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte +(OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2). + +## Wie es funktioniert + +``` +opentransportdata.swiss ──GTFS (Mo+Do)──▶ trainpulse-update (stündlich) + ├─ Filter: nur Bahn (route_type 100–199) +OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe + └─▶ /var/lib/trainpulse/feeds// +opentransportdata.swiss ──GTFS-RT (35 s)──▶ trainpulse (Webserver, Port 8080) + ├─ Position = Fahrplan + Verspätung, entlang Strecke + └─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche +``` + +Es gibt in der Schweiz **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan +und gemeldeter Verspätung berechnet und auf den Streckenverlauf gelegt. + +Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr, +die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den +sich die Echtzeitdaten gerade beziehen (`feed_version`), und schaltet automatisch mit um. + +## Installation auf Proxmox + +1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*): + Debian 12 oder 13 Template, unprivileged, 2 Kerne, **RAM_EMPFEHLUNG**, 20 GB Disk, Netz per DHCP. +2. **Projekt in den Container kopieren**, z. B. `scp trainpulse.zip root@:` und dort + `apt install -y unzip && unzip trainpulse.zip`. +3. **Installieren:** `bash trainpulse/deploy/install.sh` + Baut pfaedle, lädt die Kartenkacheln für die Schweiz, richtet die Dienste ein und startet + die erste Fahrplan-Aufbereitung im Hintergrund (`journalctl -fu trainpulse-update`). +4. **Echtzeit freischalten:** Auf registrieren, + eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in + `/etc/trainpulse.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `systemctl restart trainpulse`. + +Danach ist die Karte unter `http://:8080` erreichbar. Für den Zugriff von aussen einfach +hinter deinen bestehenden Reverse Proxy hängen. + +Ein Update des Programms: neue Version in den Container kopieren, `install.sh` nochmals ausführen. + +## Nützliche Befehle + +| Was | Befehl | +|---|---| +| Status | `curl localhost:8080/api/status` | +| Logs Webserver | `journalctl -fu trainpulse` | +| Logs Fahrplan-Update | `journalctl -fu trainpulse-update` | +| Fahrplan sofort neu aufbereiten | `sudo -u trainpulse env $(cat /etc/trainpulse.env) /opt/trainpulse/.venv/bin/python -m trainpulse.prepare --force` | +| Selbsttest | `/opt/trainpulse/.venv/bin/python tests/test_core.py` (im Projektordner) | + +## Datenquellen und Lizenzen + +Die Hinweise erscheinen unten rechts auf der Karte. + +- Fahrplan & Echtzeit: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht) +- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com) +- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL) +- Satellit Schweiz: © swisstopo (frei, Quellenangabe) +- Satellit ausserhalb: Sentinel-2 cloudless 2024 von EOX – **nur nicht-kommerziell** frei + +## Bekannte Grenzen + +- Nur Bahn. Busse, Trams und Schiffe sind im Fahrplan, werden aber noch herausgefiltert. +- Der Schweizer Fahrplan enthält auch einige ausländische Züge (z. B. TGV, TER); deren Strecke + ausserhalb der Schweiz ist nur Luftlinie, weil der OSM-Ausschnitt nur die Schweiz abdeckt. +- Züge springen alle 5 Sekunden zur neuen Position statt flüssig zu fahren. +- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen. +- In der Nacht des Fahrplanwechsels (2. Sonntag im Dezember) fehlen bis zum nächsten + stündlichen Lauf die Züge. diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..373392d --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# TrainPulse – Installation in einem Debian-LXC (Proxmox). Im Container als root: +# bash deploy/install.sh +# Erneut ausführen = Update des Programmcodes (Daten, Karte und Einstellungen bleiben). +set -euo pipefail + +APP=/opt/trainpulse +DATA=/var/lib/trainpulse +SRC=$(cd "$(dirname "$0")/.." && pwd) +BBOX=5.8,45.7,10.6,47.9 # Schweiz mit etwas Rand + +echo "==> Pakete" +apt-get update -qq +apt-get install -y -qq python3-venv git cmake g++ make libzip-dev zlib1g-dev libbz2-dev curl ca-certificates >/dev/null + +echo "==> Programm nach $APP" +id trainpulse &>/dev/null || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin trainpulse +mkdir -p "$APP" "$DATA/tiles" +rm -rf "$APP/trainpulse" "$APP/web" +cp -r "$SRC/trainpulse" "$SRC/web" "$SRC/requirements.txt" "$APP/" +[ -d "$APP/.venv" ] || python3 -m venv "$APP/.venv" +"$APP/.venv/bin/pip" install -q --upgrade -r "$APP/requirements.txt" + +if ! command -v pfaedle >/dev/null; then + echo "==> pfaedle bauen (Streckenverläufe aus OpenStreetMap, dauert ein paar Minuten)" + tmp=$(mktemp -d) + git clone -q --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle "$tmp/src" + cmake -S "$tmp/src" -B "$tmp/build" -DCMAKE_BUILD_TYPE=Release >/dev/null + make -C "$tmp/build" -j"$(nproc)" >/dev/null + make -C "$tmp/build" install >/dev/null + rm -rf "$tmp" +fi + +if ! command -v pmtiles >/dev/null; then + echo "==> pmtiles-Werkzeug" + url=$(curl -fsSL https://api.github.com/repos/protomaps/go-pmtiles/releases/latest | python3 -c \ + 'import json,sys; print(next(a["browser_download_url"] for a in json.load(sys.stdin)["assets"] if a["name"].endswith("Linux_x86_64.tar.gz")))') + curl -fsSL "$url" | tar xz -C /usr/local/bin pmtiles +fi + +if [ ! -s "$DATA/tiles/region.pmtiles" ]; then + echo "==> Kartenkacheln (OpenStreetMap via Protomaps) für $BBOX" + for i in 1 2 3 4 5 6 7; do + d=$(date -d "-$i day" +%Y%m%d) + if pmtiles extract "https://build.protomaps.com/$d.pmtiles" "$DATA/tiles/region.tmp.pmtiles" --bbox=$BBOX --maxzoom=15; then + mv "$DATA/tiles/region.tmp.pmtiles" "$DATA/tiles/region.pmtiles" + break + fi + done + [ -s "$DATA/tiles/region.pmtiles" ] || { echo "Kartenkacheln konnten nicht geladen werden" >&2; exit 1; } +fi + +if [ ! -f /etc/trainpulse.env ]; then + cat > /etc/trainpulse.env < Dienste" +cp "$SRC"/deploy/trainpulse.service "$SRC"/deploy/trainpulse-update.service "$SRC"/deploy/trainpulse-update.timer /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now trainpulse-update.timer +systemctl enable trainpulse.service +systemctl restart trainpulse.service +systemctl start --no-block trainpulse-update.service + +ip=$(hostname -I | awk '{print $1}') +cat <=0.115 +uvicorn>=0.30 +httpx>=0.27 +gtfs-realtime-bindings>=1.0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..8a5ba04 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,122 @@ +"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)""" +import datetime as dt +import json +import sys +import tempfile +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from google.transit import gtfs_realtime_pb2 as pb # noqa: E402 + +from trainpulse import prepare, realtime # noqa: E402 +from trainpulse.feed import TZ, Feed, day_start # noqa: E402 + +FILES = { + "agency.txt": "agency_id,agency_name,agency_url,agency_timezone\n11,SBB,https://sbb.ch,Europe/Zurich\n", + "routes.txt": "route_id,agency_id,route_short_name,route_long_name,route_desc,route_type\n" + "R1,11,IC5,,IC,102\nB1,11,31,,B,700\n", + "trips.txt": "route_id,service_id,trip_id,trip_headsign,trip_short_name,shape_id\n" + "R1,S,T1,Ziel,812,SH1\nB1,S,BUS,Irgendwo,1,\n", + "stop_times.txt": "trip_id,arrival_time,departure_time,stop_id,stop_sequence\n" + "T1,08:00:00,08:00:00,A,1\nT1,08:10:00,08:12:00,B,2\nT1,08:20:00,08:20:00,C,3\n" + "BUS,08:00:00,08:00:00,A,1\nBUS,08:05:00,08:05:00,X,2\n", + "stops.txt": "stop_id,stop_name,stop_lat,stop_lon,parent_station,platform_code\n" + "A,Alpha,47.0,8.0,,1\nB,Beta,47.0,8.1,,2\nC,Gamma,47.1,8.1,,3\nX,Bushalt,47.5,8.5,,\n", + "calendar.txt": "service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\n" + "S,1,1,1,1,1,1,1,20260101,20261231\n", + "calendar_dates.txt": "service_id,date,exception_type\nS,20261025,2\n", + "feed_info.txt": "feed_publisher_name,feed_version\nX,20261017\n", +} +# Streckenverlauf mit Umweg nach Süden zwischen A und B +SHAPE = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence\n" \ + "SH1,47.0,8.0,1\nSH1,46.98,8.05,2\nSH1,47.0,8.1,3\nSH1,47.1,8.1,4\n" +DAY = dt.date(2026, 10, 21) + + +def make_feed(tmp: Path, shapes=True) -> Feed: + tmp.mkdir(parents=True, exist_ok=True) + zp = tmp / "in.zip" + with zipfile.ZipFile(zp, "w") as z: + for n, c in FILES.items(): + z.writestr(n, "" + c) # wie die echten Schweizer Dateien: mit BOM + meta = prepare.filter_rail(zp, tmp / "rail") + assert meta["feed_version"] == "20261017" and meta["trips"] == 1 and meta["stop_times"] == 3, meta + if shapes: + (tmp / "rail" / "shapes.txt").write_text(SHAPE) + with open(tmp / "rail" / "trips.txt") as f: + assert "SH1" in f.read() + (tmp / "rail" / "meta.json").write_text(json.dumps(meta)) + return Feed(tmp / "rail", today=DAY) + + +def at(h, m): + return dt.datetime(DAY.year, DAY.month, DAY.day, h, m, tzinfo=TZ).timestamp() + + +def rt_feed(delay_b=None, cancel=False, version="20261017"): + fm = pb.FeedMessage() + fm.header.gtfs_realtime_version = "2.0" + fm.header.feed_version = version + e = fm.entity.add(id="1") + e.trip_update.trip.trip_id = "T1" + e.trip_update.trip.start_date = "20261021" + if cancel: + e.trip_update.trip.schedule_relationship = pb.TripDescriptor.CANCELED + s = e.trip_update.stop_time_update.add(stop_sequence=1) + s.departure.delay = 0 + if delay_b is not None: + s = e.trip_update.stop_time_update.add(stop_sequence=2) + s.arrival.delay = delay_b + s.departure.delay = delay_b + e2 = fm.entity.add(id="2") + e2.trip_update.trip.trip_id = "BUS" # nicht im Bahn-Fahrplan -> muss ignoriert werden + return fm.SerializeToString() + + +def test_all(): + assert prepare.timetable_year(dt.date(2026, 12, 12)) == 2026 + assert prepare.timetable_year(dt.date(2026, 12, 13)) == 2027 # 2. Sonntag im Dezember + assert prepare.timetable_year(dt.date(2025, 12, 14)) == 2026 + + with tempfile.TemporaryDirectory() as d: + f = make_feed(Path(d)) + # Halt B liegt auf dem Streckenverlauf bei Punkt 3, nicht auf der Luftlinie + pts, cum, sd = f.geometry(0) + assert abs(sd[1] - cum[2]) < 1 and abs(sd[2] - cum[3]) < 1 + + assert f.vehicles(at(7, 59), {}) == [] + v = f.vehicles(at(8, 5), {}) + assert len(v) == 1 and v[0]["properties"]["delay"] is None + lon, lat = v[0]["geometry"]["coordinates"] + assert lat < 47.0 and abs(lon - 8.05) < 0.01, (lon, lat) # halbe Fahrzeit = Umweg-Scheitelpunkt + assert v[0]["properties"]["next"] == "Beta" + assert f.vehicles(at(8, 11), {})[0]["geometry"]["coordinates"] == [8.1, 47.0] # steht in Beta + + ver, rt = realtime.parse(rt_feed(delay_b=300), f.trip_index) + assert ver == "20261017" and list(rt) == [("T1", "20261021")] + v = f.vehicles(at(8, 14), rt) # planmässig weg, mit 5 min Verspätung noch unterwegs nach Beta + assert v[0]["properties"]["delay"] == 300 and v[0]["properties"]["next"] == "Beta" + v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort + assert v and v[0]["properties"]["delay"] == 300 + + _, rt = realtime.parse(rt_feed(cancel=True), f.trip_index) + assert f.vehicles(at(8, 5), rt) == [] + + assert f.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates + + _, rt = realtime.parse(rt_feed(delay_b=120), f.trip_index) + det = f.trip_detail("20261021", 0, at(8, 13), rt) + assert [s["name"] for s in det["stops"]] == ["Alpha", "Beta", "Gamma"] + assert det["stops"][1]["arr"] == "08:10" and det["stops"][1]["arr_delay"] == 120 + assert det["stops"][0]["passed"] and not det["stops"][1]["passed"] + assert day_start(DAY) == at(0, 0) + + g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie + assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0] + print("alle Tests ok") + + +if __name__ == "__main__": + test_all() diff --git a/trainpulse/__init__.py b/trainpulse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trainpulse/config.py b/trainpulse/config.py new file mode 100644 index 0000000..f055c0c --- /dev/null +++ b/trainpulse/config.py @@ -0,0 +1,16 @@ +import os +from pathlib import Path + +DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse")) +FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne, ein Ordner pro feed_version +TZ = "Europe/Zurich" + +# Schweiz: offizieller GTFS-Fahrplan + GTFS-RT (Trip Updates) von opentransportdata.swiss +GTFS_URL = os.environ.get("TRAINPULSE_GTFS_URL", "") # leer = automatisch das aktuelle Fahrplanjahr +RT_URL = os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt") +RT_KEY = os.environ.get("TRAINPULSE_RT_KEY", "") +RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window) + +OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf") +PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle") +USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)" diff --git a/trainpulse/feed.py b/trainpulse/feed.py new file mode 100644 index 0000000..046c26a --- /dev/null +++ b/trainpulse/feed.py @@ -0,0 +1,293 @@ +"""Hält einen aufbereiteten Bahn-Fahrplan im Speicher und berechnet daraus Zugpositionen +entlang der Strecke (Fahrplan + Echtzeit-Verspätung -> Position auf dem Streckenverlauf).""" +import csv +import datetime as dt +import json +import math +from array import array +from bisect import bisect_right +from pathlib import Path +from zoneinfo import ZoneInfo + +from . import config as C + +TZ = ZoneInfo(C.TZ) +CAL_DAYS = 14 # so viele Tage Ausnahmen aus calendar_dates laden (die Datei ist riesig) + + +def hms(s: str) -> int: + h, m, sec = s.split(":") + return int(h) * 3600 + int(m) * 60 + int(sec) + + +def day_start(d: dt.date) -> float: + # GTFS-Zeiten zählen ab "Mittag minus 12 h" – stimmt auch an Tagen mit Zeitumstellung + return dt.datetime(d.year, d.month, d.day, 12, tzinfo=TZ).timestamp() - 43200 + + +def read(path: Path): + with open(path, encoding="utf-8-sig", newline="") as f: + r = csv.reader(f) + h = {k: i for i, k in enumerate(next(r))} + for row in r: + yield h, row + + +def xy(lon, lat, lat0): # lokale Projektion in Metern, reicht für Abstände entlang einer Strecke + return lon * 111320 * math.cos(math.radians(lat0)), lat * 110540 + + +class Feed: + def __init__(self, path: Path, today: dt.date | None = None): + self.path = path + self.meta = json.loads((path / "meta.json").read_text()) + self.version = self.meta["feed_version"] + + self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], [] + for h, r in read(path / "stops.txt"): + self.stop_index[r[h["stop_id"]]] = len(self.stop_name) + self.stop_name.append(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 "") + + agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")} + self.routes = {} + for h, r in read(path / "routes.txt"): + self.routes[r[h["route_id"]]] = { + "line": r[h["route_short_name"]] or r[h.get("route_long_name", 0)], + "cat": r[h["route_desc"]] if "route_desc" in h else "", + "agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "", + } + + self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], [] + self.trip_headsign, self.trip_number, self.trip_shape = [], [], [] + for h, r in read(path / "trips.txt"): + self.trip_index[r[h["trip_id"]]] = len(self.trip_route) + self.trip_ids.append(r[h["trip_id"]]) + self.trip_route.append(r[h["route_id"]]) + self.trip_service.append(r[h["service_id"]]) + self.trip_headsign.append(r[h["trip_headsign"]] if "trip_headsign" in h else "") + self.trip_number.append(r[h["trip_short_name"]] if "trip_short_name" in h else "") + self.trip_shape.append(r[h["shape_id"]] if "shape_id" in h else "") + + # Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i + n = len(self.trip_route) + self.off = array("i", [0]) * (n + 1) + self.st_stop, self.st_seq, self.st_arr, self.st_dep = array("i"), array("i"), array("i"), array("i") + rows = {} + for h, r in read(path / "stop_times.txt"): + rows.setdefault(self.trip_index[r[h["trip_id"]]], []).append( + (int(r[h["stop_sequence"]]), self.stop_index[r[h["stop_id"]]], hms(r[h["arrival_time"]]), hms(r[h["departure_time"]]))) + for ti in range(n): + self.off[ti] = len(self.st_stop) + for seq, s, a, d in sorted(rows.pop(ti, ())): + self.st_seq.append(seq) + self.st_stop.append(s) + self.st_arr.append(a) + self.st_dep.append(d) + self.off[n] = len(self.st_stop) + + self.shapes = {} + if (path / "shapes.txt").exists(): + pts = {} + for h, r in read(path / "shapes.txt"): + pts.setdefault(r[h["shape_id"]], []).append( + (int(r[h["shape_pt_sequence"]]), float(r[h["shape_pt_lon"]]), float(r[h["shape_pt_lat"]]))) + for sid, p in pts.items(): + p.sort() + self.shapes[sid] = [(lon, lat) for _, lon, lat in p] + + self.calendar = {} + for h, r in read(path / "calendar.txt"): + self.calendar[r[h["service_id"]]] = ( + [r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")], + r[h["start_date"]], r[h["end_date"]]) + today = today or dt.datetime.now(TZ).date() + self.cal_from = today - dt.timedelta(days=1) + self.cal_until = today + dt.timedelta(days=CAL_DAYS) + window = {(self.cal_from + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range(CAL_DAYS + 2)} + self.exceptions = {} # datum -> {service_id: 1 (fährt) | 2 (fährt nicht)} + cd = path / "calendar_dates.txt" + if cd.exists(): + with open(cd, encoding="utf-8-sig") as f: + assert next(f).strip().replace('"', "").startswith("service_id,date,exception_type") + for line in f: + sid, date, typ = line.rstrip("\r\n").rsplit(",", 2) + date = date.strip('"') + if date in window: + self.exceptions.setdefault(date, {})[sid.strip('"')] = int(typ.strip('"')) + + self._day_cache = {} + self._geom_cache = {} + + # ---------- Kalender ---------- + + def trips_on(self, d: dt.date): + """Fahrten eines Betriebstags, sortiert nach Abfahrt: (start, ende, trip_index).""" + if d not in self._day_cache: + ds = d.strftime("%Y%m%d") + exc = self.exceptions.get(ds, {}) + active = {sid for sid, (days, a, b) in self.calendar.items() if a <= ds <= b and days[d.weekday()]} + active |= {sid for sid, t in exc.items() if t == 1} + active -= {sid for sid, t in exc.items() if t == 2} + lst = sorted((self.st_dep[self.off[ti]], self.st_arr[self.off[ti + 1] - 1], ti) + for ti, sid in enumerate(self.trip_service) + if sid in active and self.off[ti + 1] - self.off[ti] >= 2) + if len(self._day_cache) > 4: + self._day_cache.clear() + self._day_cache[d] = lst + return self._day_cache[d] + + def covers(self, d: dt.date) -> bool: + return self.cal_from <= d <= self.cal_until + + # ---------- Geometrie ---------- + + def geometry(self, ti: int): + """Streckenverlauf einer Fahrt: Punkte, kumulierte Meter, Meter-Position jedes Halts.""" + o0, o1 = self.off[ti], self.off[ti + 1] + stops = tuple(self.st_stop[o0:o1]) + key = (self.trip_shape[ti], stops) + g = self._geom_cache.get(key) + if g: + return g + shape = self.shapes.get(self.trip_shape[ti]) + pts = shape or [(self.stop_lon[s], self.stop_lat[s]) for s in stops] + lat0 = pts[0][1] + P = [xy(lon, lat, lat0) for lon, lat in pts] + cum = [0.0] + for (x1, y1), (x2, y2) in zip(P, P[1:]): + cum.append(cum[-1] + math.hypot(x2 - x1, y2 - y1)) + if shape: + stop_d, j = [], 0 + for s in stops: + px, py = xy(self.stop_lon[s], self.stop_lat[s], lat0) + best = (float("inf"), cum[j], j) + for k in range(j, len(P) - 1): + (x1, y1), (x2, y2) = P[k], P[k + 1] + dx, dy = x2 - x1, y2 - y1 + seg = dx * dx + dy * dy + t = 0.0 if seg == 0 else max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / seg)) + dist = math.hypot(x1 + t * dx - px, y1 + t * dy - py) + if dist < best[0]: + best = (dist, cum[k] + t * (cum[k + 1] - cum[k]), k) + elif best[0] < 100 and dist > best[0] + 1000: + break # nächster Halt gefunden, nicht bis ans Streckenende weitersuchen + stop_d.append(best[1]) + j = best[2] + else: + stop_d = cum + g = (pts, cum, stop_d) + self._geom_cache[key] = g + return g + + @staticmethod + def point_at(g, d: float): + pts, cum, _ = g + k = max(0, min(bisect_right(cum, d) - 1, len(pts) - 2)) + seg = cum[k + 1] - cum[k] + t = 0.0 if seg <= 0 else (d - cum[k]) / seg + (x1, y1), (x2, y2) = pts[k], pts[k + 1] + return round(x1 + t * (x2 - x1), 6), round(y1 + t * (y2 - y1), 6) + + # ---------- Echtzeit ---------- + + def times(self, ti: int, ds: float, upd): + """Ankunft/Abfahrt je Halt (Sekunden ab Betriebstag), mit Verspätungen aus GTFS-RT. + Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird.""" + o0, o1 = self.off[ti], self.off[ti + 1] + arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1]) + if not upd: + return arr, dep + seqs = self.st_seq[o0:o1] + by_i = {} + for u in upd: + i = bisect_right(seqs, u[0]) - 1 + if i >= 0 and seqs[i] == u[0]: + by_i[i] = u + cur, prev = None, -10 ** 9 + for i in range(len(arr)): + u = by_i.get(i) + if u and u[1] is not None: + a = u[1] - ds if u[2] else arr[i] + u[1] + cur = a - arr[i] + else: + a = arr[i] + (cur or 0) + if u and u[3] is not None: + d = u[3] - ds if u[4] else dep[i] + u[3] + cur = d - dep[i] + else: + d = dep[i] + (cur or 0) + a = max(a, prev) + d = max(d, a) + arr[i], dep[i], prev = a, d, d + return arr, dep + + # ---------- Positionen ---------- + + def vehicles(self, now: float, rt: dict): + feats = [] + today = dt.datetime.fromtimestamp(now, TZ).date() + for d in (today - dt.timedelta(days=1), today): + ds, dstr = day_start(d), d.strftime("%Y%m%d") + t = now - ds + for start, end, ti in self.trips_on(d): + if start > t + 600: + break # Liste ist nach Abfahrt sortiert + if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden + continue + r = rt.get((self.trip_ids[ti], dstr)) + if r and r[0]: + continue # ausgefallen + arr, dep = self.times(ti, ds, r[1] if r else None) + if not (dep[0] <= t <= arr[-1]): + continue + i = min(bisect_right(dep, t), len(dep) - 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] + if t >= arr[i]: # steht am Halt i + dist, delay = sd[i], dep[i] - self.st_dep[o + i] + else: + f = (t - dep[i - 1]) / max(1, arr[i] - dep[i - 1]) + dist, delay = sd[i - 1] + f * (sd[i] - sd[i - 1]), arr[i] - self.st_arr[o + i] + lon, lat = self.point_at(g, dist) + route = self.routes[self.trip_route[ti]] + feats.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": { + "id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], + "nr": self.trip_number[ti], "to": self.trip_headsign[ti], + "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]], + }, + }) + return feats + + def trip_detail(self, date: str, ti: int, now: float, rt: dict): + d = dt.datetime.strptime(date, "%Y%m%d").date() + ds = day_start(d) + r = rt.get((self.trip_ids[ti], date)) + arr, dep = self.times(ti, ds, r[1] if r else None) + o0, o1 = self.off[ti], self.off[ti + 1] + + def hhmm(sec): + return dt.datetime.fromtimestamp(ds + sec, TZ).strftime("%H:%M") + + stops = [] + for k, i in enumerate(range(o0, o1)): + s = self.st_stop[i] + stops.append({ + "name": self.stop_name[s], "platform": self.stop_platform[s], + "arr": hhmm(self.st_arr[i]) if k > 0 else None, + "dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None, + "arr_delay": arr[k] - self.st_arr[i] if r else None, + "dep_delay": dep[k] - self.st_dep[i] if r else None, + "passed": now - ds > dep[k], + }) + route = self.routes[self.trip_route[ti]] + return {"line": route["line"], "cat": route["cat"], "agency": route["agency"], + "nr": self.trip_number[ti], "to": self.trip_headsign[ti], + "realtime": bool(r), "canceled": bool(r and r[0]), + "stops": stops, "shape": self.geometry(ti)[0]} diff --git a/trainpulse/prepare.py b/trainpulse/prepare.py new file mode 100644 index 0000000..40aed0c --- /dev/null +++ b/trainpulse/prepare.py @@ -0,0 +1,226 @@ +"""Fahrplan-Update: holt den Schweizer GTFS-Fahrplan, filtert auf Bahn und rechnet mit pfaedle +die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich per systemd-Timer und +macht nichts, solange opentransportdata.swiss keinen neuen Fahrplan veröffentlicht hat. + + python -m trainpulse.prepare # normaler Lauf + python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen) +""" +import argparse +import csv +import datetime as dt +import io +import json +import logging +import shutil +import subprocess +import time +import zipfile +from pathlib import Path + +import httpx + +from . import config as C + +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) + + +def timetable_year(today: dt.date) -> int: + # Fahrplanwechsel ist am 2. Sonntag im Dezember + if today.month == 12: + first = dt.date(today.year, 12, 1) + if today >= first + dt.timedelta(days=(6 - first.weekday()) % 7 + 7): + return today.year + 1 + return today.year + + +def gtfs_url() -> str: + # shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge + return C.GTFS_URL or ( + f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink") + + +def download(url: str, dest: Path) -> None: + tmp = dest.with_suffix(dest.suffix + ".part") + with httpx.stream("GET", url, follow_redirects=True, timeout=120, + headers={"User-Agent": C.USER_AGENT}) as r: + r.raise_for_status() + with open(tmp, "wb") as f: + for chunk in r.iter_bytes(1 << 20): + f.write(chunk) + tmp.rename(dest) + + +def latest_gtfs_name(url: str) -> str: + # Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht, + # darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt. + r = httpx.get(url, follow_redirects=False, timeout=60, headers={"User-Agent": C.USER_AGENT}) + if r.status_code not in (301, 302, 303, 307, 308): + r.raise_for_status() + raise RuntimeError(f"Keine Weiterleitung von {url} (Status {r.status_code})") + return r.headers["location"].rsplit("/", 1)[-1].split("?")[0] + + +def rows(z: zipfile.ZipFile, name: str): + reader = csv.reader(io.TextIOWrapper(z.open(name), encoding="utf-8-sig", newline="")) + header = next(reader) + return header, reader + + +def write_csv(path: Path, header, data) -> int: + n = 0 + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(header) + for row in data: + w.writerow(row) + n += 1 + return n + + +def filter_by_first_col(z: zipfile.ZipFile, name: str, keys: set, out: Path, collect: str | None = None): + """Schnellfilter für die Riesendateien (stop_times.txt hat ~3.7 GB): vergleicht nur das erste Feld + als Rohtext, schreibt Treffer unverändert weg. Gibt (Anzahl, Werte der Spalte `collect`) zurück.""" + n, seen = 0, set() + with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f: + lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="") + header = next(csv.reader([lines.readline()])) + assert header[0] in ("trip_id", "service_id"), header + f.write(",".join(header) + "\n") + ci = header.index(collect) if collect else None + for line in lines: + if line.split(",", 1)[0].strip('"') in keys: + f.write(line) + n += 1 + if ci is not None: + seen.add(next(csv.reader([line]))[ci]) + return n, seen + + +def filter_rail(zip_path: Path, out: Path) -> dict: + out.mkdir(parents=True) + z = zipfile.ZipFile(zip_path) + + h, rd = rows(z, "routes.txt") + routes = [r for r in rd if is_rail(r[h.index("route_type")])] + write_csv(out / "routes.txt", h, routes) + route_ids = {r[h.index("route_id")] for r in routes} + + h, rd = rows(z, "trips.txt") + trips = [r for r in rd if r[h.index("route_id")] in route_ids] + write_csv(out / "trips.txt", h, trips) + trip_ids = {r[h.index("trip_id")] for r in trips} + service_ids = {r[h.index("service_id")] for r in trips} + + if "frequencies.txt" in z.namelist(): + fh, frd = rows(z, "frequencies.txt") + n = sum(1 for r in frd if r[0] in trip_ids) + if n: + log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n) + + n_st, used_stops = filter_by_first_col(z, "stop_times.txt", trip_ids, out / "stop_times.txt", "stop_id") + + h, rd = rows(z, "stops.txt") + stops = list(rd) + parents = {r[h.index("parent_station")] for r in stops if r[0] in used_stops} + write_csv(out / "stops.txt", h, (r for r in stops if r[0] in used_stops or r[0] in parents)) + + for name in ("calendar.txt", "calendar_dates.txt"): + if name in z.namelist(): + filter_by_first_col(z, name, service_ids, out / name) + for name in ("agency.txt", "feed_info.txt"): + if name in z.namelist(): + h, rd = rows(z, name) + write_csv(out / name, h, rd) + + h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([])) + info = dict(zip(h, next(rd, []))) + return {"feed_version": info.get("feed_version") or zip_path.stem, + "routes": len(routes), "trips": len(trips), "stop_times": n_st} + + +def ensure_osm() -> Path: + osm = C.DATA_DIR / "osm" / "region.osm.pbf" + if not osm.exists() or time.time() - osm.stat().st_mtime > 30 * 86400: + osm.parent.mkdir(parents=True, exist_ok=True) + log.info("Lade OSM-Daten %s", C.OSM_URL) + download(C.OSM_URL, osm) + return osm + + +def run_pfaedle(work: Path, rail: Path) -> Path | None: + if not shutil.which(C.PFAEDLE): + log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen") + return None + osm = ensure_osm() + log.info("pfaedle rechnet Streckenverläufe …") + t = time.time() + res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work) + out = work / "gtfs-out" + if res.returncode != 0 or not (out / "shapes.txt").exists(): + log.error("pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", res.returncode) + return None + log.info("pfaedle fertig in %.0f s", time.time() - t) + return out + + +def publish(src: Path, meta: dict) -> None: + (src / "meta.json").write_text(json.dumps(meta)) + dest = C.FEEDS_DIR / meta["feed_version"] + if dest.exists(): + shutil.rmtree(dest) + src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner + for old in sorted(p for p in C.FEEDS_DIR.iterdir() if p.is_dir())[:-3]: + shutil.rmtree(old) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download") + ap.add_argument("--force", action="store_true") + args = ap.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + C.FEEDS_DIR.mkdir(parents=True, exist_ok=True) + state_file = C.DATA_DIR / "state.json" + state = json.loads(state_file.read_text()) if state_file.exists() else {} + work = C.DATA_DIR / "work" + shutil.rmtree(work, ignore_errors=True) + work.mkdir() + + if args.zip: + zip_path, name = args.zip, args.zip.name + else: + url = gtfs_url() + name = latest_gtfs_name(url) + if name == state.get("last_file") and not args.force: + log.info("Kein neuer Fahrplan (%s)", name) + return + zip_path = work / name + log.info("Lade %s", name) + download(url, zip_path) + + t = time.time() + meta = filter_rail(zip_path, work / "rail") + log.info("Bahn-Filter: %s in %.0f s", meta, time.time() - t) + if not args.zip: + zip_path.unlink() + + shaped = run_pfaedle(work, work / "rail") + meta["shapes"] = shaped is not None + meta["source_file"] = name + meta["prepared_at"] = int(time.time()) + publish(shaped or work / "rail", meta) + + state["last_file"] = name + state_file.write_text(json.dumps(state)) + shutil.rmtree(work, ignore_errors=True) + log.info("Fahrplan %s bereit", meta["feed_version"]) + + +if __name__ == "__main__": + main() diff --git a/trainpulse/realtime.py b/trainpulse/realtime.py new file mode 100644 index 0000000..68e597a --- /dev/null +++ b/trainpulse/realtime.py @@ -0,0 +1,41 @@ +"""GTFS-RT Trip Updates von opentransportdata.swiss holen und auf das Nötigste eindampfen.""" +import httpx +from google.transit import gtfs_realtime_pb2 as pb + +from . import config as C + +CANCELED = pb.TripDescriptor.CANCELED + + +def parse(data: bytes, known_trips: dict): + """-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, ank, ank_absolut, abf, abf_absolut), ...])}) + ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe.""" + feed = pb.FeedMessage() + feed.ParseFromString(data) + out = {} + for e in feed.entity: + if not e.HasField("trip_update"): + continue + tu = e.trip_update + if tu.trip.trip_id not in known_trips: + continue # nicht Bahn + upd = [] + for s in tu.stop_time_update: + ev = [] + for name in ("arrival", "departure"): + if s.HasField(name): + x = getattr(s, name) + ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False] + else: + ev += [None, False] + upd.append((s.stop_sequence, *ev)) + out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd) + version = feed.header.feed_version if feed.header.HasField("feed_version") else None + return version, out + + +def fetch() -> bytes: + r = httpx.get(C.RT_URL, follow_redirects=True, timeout=30, headers={ + "Authorization": f"Bearer {C.RT_KEY}", "User-Agent": C.USER_AGENT}) + r.raise_for_status() + return r.content diff --git a/trainpulse/server.py b/trainpulse/server.py new file mode 100644 index 0000000..0508b64 --- /dev/null +++ b/trainpulse/server.py @@ -0,0 +1,135 @@ +"""Webserver: liefert die Karte aus und berechnet laufend die Zugpositionen. + + uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080 +""" +import datetime as dt +import gzip +import json +import logging +import threading +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.staticfiles import StaticFiles + +from . import config as C +from . import realtime +from .feed import TZ, Feed + +log = logging.getLogger("trainpulse") +WEB = Path(__file__).resolve().parent.parent / "web" + + +class State: + feed: Feed | None = None + rt: dict = {} + rt_version: str | None = None + rt_at: float | None = None + rt_error: str | None = None + vehicles: bytes = b'{"type":"FeatureCollection","features":[]}' + vehicles_gz: bytes = gzip.compress(vehicles) + count = 0 + computed_at: float | None = None + + +S = State() + + +def loop(every: float, fn): + def run(): + while True: + t = time.time() + try: + fn() + except Exception: + log.exception("Fehler in %s", fn.__name__) + time.sleep(max(1.0, every - (time.time() - t))) + threading.Thread(target=run, daemon=True, name=fn.__name__).start() + + +def watch_feeds(): + """Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten + erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten.""" + ready = sorted(m.parent.name for m in C.FEEDS_DIR.glob("*/meta.json")) + if not ready: + return + want = S.rt_version if S.rt_version in ready else ready[-1] + today = dt.datetime.now(TZ).date() + if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)): + return + log.info("Lade Fahrplan %s …", want) + t = time.time() + S.feed = Feed(C.FEEDS_DIR / want) + log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids)) + + +def poll_realtime(): + if not C.RT_KEY or not S.feed: + return + try: + S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index) + S.rt_at, S.rt_error = time.time(), None + except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen + S.rt_error = str(e) + if S.rt_at and time.time() - S.rt_at > 600: + S.rt = {} + raise + + +def compute_positions(): + if not S.feed: + return + feats = S.feed.vehicles(time.time(), S.rt) + S.vehicles = json.dumps({"type": "FeatureCollection", "features": feats}, separators=(",", ":")).encode() + S.vehicles_gz = gzip.compress(S.vehicles, 5) + S.count, S.computed_at = len(feats), time.time() + + +@asynccontextmanager +async def lifespan(app): + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + C.FEEDS_DIR.mkdir(parents=True, exist_ok=True) + loop(60, watch_feeds) + loop(C.RT_INTERVAL, poll_realtime) + loop(3, compute_positions) + yield + + +app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None) + + +@app.get("/api/vehicles") +def vehicles(request: Request): + headers = {"Cache-Control": "no-store"} + if "gzip" in request.headers.get("accept-encoding", ""): + return Response(S.vehicles_gz, media_type="application/json", headers={**headers, "Content-Encoding": "gzip"}) + return Response(S.vehicles, media_type="application/json", headers=headers) + + +@app.get("/api/trip/{date}/{ti}") +def trip(date: str, ti: int): + f = S.feed + if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()): + raise HTTPException(404) + return f.trip_detail(date, ti, time.time(), S.rt) + + +@app.get("/api/status") +def status(): + f = S.feed + return { + "feed_version": f.version if f else None, + "shapes": f.meta.get("shapes") if f else None, + "realtime": bool(C.RT_KEY), + "rt_version": S.rt_version, + "rt_age": round(time.time() - S.rt_at) if S.rt_at else None, + "rt_error": S.rt_error, + "rt_trips": len(S.rt), + "vehicles": S.count, + } + + +app.mount("/tiles", StaticFiles(directory=C.DATA_DIR / "tiles", check_dir=False), name="tiles") # PMTiles, Range-Requests +app.mount("/", StaticFiles(directory=WEB, html=True), name="web") diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..4c9b96a --- /dev/null +++ b/web/app.js @@ -0,0 +1,180 @@ +"use strict"; + +const EMPTY = { type: "FeatureCollection", features: [] }; +const protocol = new pmtiles.Protocol(); +maplibregl.addProtocol("pmtiles", protocol.tile); + +const flavor = basemaps.namedFlavor("light"); +const baseLayers = basemaps.layers("osm", flavor, { lang: "de" }); +const labelIds = new Set(basemaps.layers("osm", flavor, { labelsOnly: true, lang: "de" }).map((l) => l.id)); + +// Verspätung in Sekunden -> Farbe (SBB zählt < 3 min als pünktlich) +const delayColor = ["case", + ["==", ["get", "delay"], null], "#94a3b8", + ["<", ["get", "delay"], 180], "#16a34a", + ["<", ["get", "delay"], 360], "#eab308", + ["<", ["get", "delay"], 900], "#f97316", + "#dc2626"]; + +const map = new maplibregl.Map({ + container: "map", + center: [8.23, 46.82], + zoom: 7.4, + hash: true, + style: { + version: 8, + glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", + sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", + sources: { + osm: { + type: "vector", url: `pmtiles://${location.origin}/tiles/region.pmtiles`, + attribution: 'Protomaps © OpenStreetMap-Mitwirkende', + }, + s2: { + type: "raster", tileSize: 256, maxzoom: 14, + tiles: ["https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2024_3857/default/g/{z}/{y}/{x}.jpg"], + attribution: 'Sentinel-2 cloudless by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)', + }, + swissimage: { + type: "raster", tileSize: 256, minzoom: 8, maxzoom: 19, bounds: [5.96, 45.82, 10.49, 47.81], + tiles: ["https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg"], + attribution: '© swisstopo', + }, + route: { type: "geojson", data: EMPTY }, + trains: { + type: "geojson", data: EMPTY, + attribution: 'Fahrplan & Echtzeit: opentransportdata.swiss', + }, + }, + layers: [ + { id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } }, + { id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } }, + ...baseLayers, + { id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } }, + { id: "trains", type: "circle", source: "trains", + paint: { + "circle-color": delayColor, + "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 3, 10, 5.5, 14, 9], + "circle-stroke-color": "#fff", + "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 12, 1.8], + } }, + { id: "train-labels", type: "symbol", source: "trains", minzoom: 10, + layout: { + "text-field": ["concat", ["get", "line"], " ", ["get", "nr"]], + "text-font": ["Noto Sans Medium"], "text-size": 11, + "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true, + }, + paint: { "text-color": "#111827", "text-halo-color": "#fff", "text-halo-width": 1.5 } }, + ], + }, +}); +map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "bottom-right"); +map.addControl(new maplibregl.ScaleControl(), "bottom-left"); + +// ---------- Karte / Satellit ---------- + +function setBackground(sat) { + for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", sat ? "visible" : "none"); + for (const l of baseLayers) { + if (!labelIds.has(l.id)) map.setLayoutProperty(l.id, "visibility", sat ? "none" : "visible"); + } + document.getElementById("btn-map").setAttribute("aria-pressed", String(!sat)); + document.getElementById("btn-sat").setAttribute("aria-pressed", String(sat)); + try { localStorage.setItem("sat", sat ? "1" : ""); } catch (e) { /* privater Modus */ } +} +document.getElementById("btn-map").onclick = () => setBackground(false); +document.getElementById("btn-sat").onclick = () => setBackground(true); + +// ---------- Züge ---------- + +async function refreshTrains() { + if (document.hidden) return; + try { + const r = await fetch("api/vehicles"); + if (r.ok) map.getSource("trains").setData(await r.json()); + } catch (e) { /* nächster Versuch in ein paar Sekunden */ } +} + +async function refreshStatus() { + const el = document.getElementById("status"); + try { + const s = await (await fetch("api/status")).json(); + if (!s.feed_version) { el.textContent = "Noch kein Fahrplan geladen …"; el.className = "warn"; return; } + const v = s.feed_version; + const parts = [`Fahrplan ${v.slice(6, 8)}.${v.slice(4, 6)}.${v.slice(0, 4)}`, `${s.vehicles} Züge`]; + let warn = !s.shapes; + if (!s.realtime) { parts.push("keine Echtzeit (API-Key fehlt)"); warn = true; } + else if (s.rt_error || s.rt_age === null || s.rt_age > 180) { parts.push("Echtzeit gestört"); warn = true; } + else parts.push(`Echtzeit vor ${s.rt_age} s`); + if (!s.shapes) parts.push("Strecken: Luftlinie"); + el.textContent = parts.join(" · "); + el.className = warn ? "warn" : ""; + } catch (e) { + el.textContent = "Server nicht erreichbar"; + el.className = "warn"; + } +} + +// ---------- Zugdetails ---------- + +let openId = null; +const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + +function delayBadge(sec) { + if (sec === null || sec === undefined) return 'ohne Echtzeit'; + const min = Math.round(sec / 60); + const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3"; + return `${min <= 0 ? "pünktlich" : "+" + min + "′"}`; +} + +function delayText(sec) { + if (sec === null || sec === undefined || Math.round(sec / 60) === 0) return ""; + const min = Math.round(sec / 60); + const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3"; + return `${min > 0 ? "+" : ""}${min}′`; +} + +async function showTrip(id) { + openId = id; + const r = await fetch(`api/trip/${id}`); + if (!r.ok || openId !== id) return; + const t = await r.json(); + const next = t.stops.find((s) => !s.passed); + const curDelay = t.realtime ? (next ? (next.arr_delay ?? next.dep_delay) : t.stops.at(-1).arr_delay) : null; + document.getElementById("detail").innerHTML = ` +

${esc(t.line)} ${esc(t.nr)} ${t.canceled ? 'fällt aus' : delayBadge(curDelay)}

+

nach ${esc(t.to)} · ${esc(t.agency)}

+
    ${t.stops.map((s) => ` +
  • + ${esc(s.arr ?? s.dep)} + ${esc(s.name)}${s.platform ? ` Gl. ${esc(s.platform)}` : ""} + ${delayText(s.arr !== null ? s.arr_delay : s.dep_delay)} +
  • `).join("")} +
`; + document.getElementById("panel").hidden = false; + map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } }); +} + +function closeTrip() { + openId = null; + document.getElementById("panel").hidden = true; + map.getSource("route").setData(EMPTY); +} +document.getElementById("close").onclick = closeTrip; + +map.on("click", "trains", (e) => showTrip(e.features[0].properties.id)); +map.on("mouseenter", "trains", () => { map.getCanvas().style.cursor = "pointer"; }); +map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; }); + +map.on("load", () => { + let sat = false; + try { sat = localStorage.getItem("sat") === "1"; } catch (e) { /* egal */ } + setBackground(sat); + refreshTrains(); + refreshStatus(); + // shortcut: Züge springen alle 5 s; für flüssige Bewegung clientseitig interpolieren + setInterval(refreshTrains, 5000); + setInterval(refreshStatus, 30000); + setInterval(() => openId && showTrip(openId), 30000); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4f13e1f --- /dev/null +++ b/web/index.html @@ -0,0 +1,39 @@ + + + + + +TrainPulse + + + + + + + +
+ +
+

TrainPulse

+
+ + +
+
    +
  • pünktlich
  • +
  • 3–6′
  • +
  • 6–15′
  • +
  • >15′
  • +
  • ohne Echtzeit
  • +
+

Lade …

+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..52ff389 --- /dev/null +++ b/web/style.css @@ -0,0 +1,45 @@ +:root { + --bg: #fff; --fg: #1f2937; --muted: #6b7280; --line: #e5e7eb; --accent: #b91c1c; + --ok: #16a34a; --d1: #eab308; --d2: #f97316; --d3: #dc2626; --na: #94a3b8; + font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--fg); +} +@media (prefers-color-scheme: dark) { + :root { --bg: #111827; --fg: #e5e7eb; --muted: #9ca3af; --line: #374151; } +} +html, body { margin: 0; height: 100%; } +#map { position: absolute; inset: 0; } + +.card { + position: absolute; z-index: 2; background: var(--bg); border-radius: 10px; + box-shadow: 0 2px 12px rgba(0,0,0,.18); +} +#bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(340px, calc(100vw - 24px)); box-sizing: border-box; } +h1 { margin: 0 0 8px; font-size: 16px; letter-spacing: .02em; } +h1::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); margin-right: 7px; } + +.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 14px; font: inherit; cursor: pointer; } +.seg 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 i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--c); margin-right: 4px; } +#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); } +#status.warn { color: var(--d2); } + +#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; } +#detail h2 { margin: 0; font-size: 18px; } +#detail .sub { color: var(--muted); margin: 2px 0 10px; } +.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; color: #fff; font-size: 12px; font-weight: 600; vertical-align: 2px; } +.stops { list-style: none; margin: 0; padding: 0; } +.stops li { display: grid; grid-template-columns: 46px 1fr auto; gap: 0 8px; padding: 5px 0; border-top: 1px solid var(--line); } +.stops li.passed { opacity: .45; } +.stops .t { font-variant-numeric: tabular-nums; } +.stops .d { font-size: 12px; font-weight: 600; } +.stops .pl { color: var(--muted); font-size: 12px; } + +@media (max-width: 640px) { + #panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; } + .legend { display: none; } +}