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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Guido
2026-10-21 14:59:08 +02:00
co-authored by Claude Opus 5.5
commit d92dac4a00
17 changed files with 1292 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc
+72
View File
@@ -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/<version>/
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@<ip>:` 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 <https://api-manager.opentransportdata.swiss> 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://<ip>: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.
+79
View File
@@ -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 <<EOF
TRAINPULSE_DATA=$DATA
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT")
TRAINPULSE_RT_KEY=
EOF
chmod 640 /etc/trainpulse.env
chgrp trainpulse /etc/trainpulse.env
fi
chown -R trainpulse:trainpulse "$DATA"
echo "==> 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 <<EOF
Fertig. Die Karte läuft unter http://$ip:8080
Der erste Fahrplan wird jetzt im Hintergrund geladen und aufbereitet (ca. 10–20 min).
Fortschritt: journalctl -fu trainpulse-update
Echtzeit: API-Key in /etc/trainpulse.env eintragen, dann systemctl restart trainpulse
EOF
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=TrainPulse: neuen Fahrplan holen und Streckenverläufe berechnen
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/python -m trainpulse.prepare
Nice=10
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=TrainPulse: stündlich auf neuen Fahrplan prüfen
[Timer]
OnBootSec=2min
OnCalendar=*-*-* *:20:00
Persistent=true
[Install]
WantedBy=timers.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=TrainPulse Webserver
After=network-online.target
Wants=network-online.target
[Service]
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080 --proxy-headers
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn>=0.30
httpx>=0.27
gtfs-realtime-bindings>=1.0
+122
View File
@@ -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()
View File
+16
View File
@@ -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)"
+293
View File
@@ -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]}
+226
View File
@@ -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()
+41
View File
@@ -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
+135
View File
@@ -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")
+180
View File
@@ -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: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap-Mitwirkende</a>',
},
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: '<a href="https://s2maps.eu">Sentinel-2 cloudless</a> 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: '© <a href="https://www.swisstopo.admin.ch">swisstopo</a>',
},
route: { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
},
},
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function delayBadge(sec) {
if (sec === null || sec === undefined) return '<span class="badge" style="background:var(--na)">ohne Echtzeit</span>';
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="badge" style="background:var(${c})">${min <= 0 ? "pünktlich" : "+" + min + "′"}</span>`;
}
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 `<span class="d" style="color:var(${c})">${min > 0 ? "+" : ""}${min}′</span>`;
}
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 = `
<h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2>
<p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p>
<ul class="stops">${t.stops.map((s) => `
<li class="${s.passed ? "passed" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span>
${delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
</li>`).join("")}
</ul>`;
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);
});
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TrainPulse</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="style.css">
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@4.5.0/dist/pmtiles.js"></script>
<script src="https://unpkg.com/@protomaps/basemaps@5.7.2/dist/basemaps.js"></script>
</head>
<body>
<div id="map"></div>
<header class="card" id="bar">
<h1>TrainPulse</h1>
<div class="seg" role="group" aria-label="Hintergrund">
<button id="btn-map" aria-pressed="true">Karte</button>
<button id="btn-sat" aria-pressed="false">Satellit</button>
</div>
<ul class="legend">
<li><i style="--c:var(--ok)"></i>pünktlich</li>
<li><i style="--c:var(--d1)"></i>3–6′</li>
<li><i style="--c:var(--d2)"></i>6–15′</li>
<li><i style="--c:var(--d3)"></i>&gt;15′</li>
<li><i style="--c:var(--na)"></i>ohne Echtzeit</li>
</ul>
<p id="status">Lade …</p>
</header>
<aside class="card" id="panel" hidden>
<button id="close" aria-label="Schliessen">×</button>
<div id="detail"></div>
</aside>
<script src="app.js"></script>
</body>
</html>
+45
View File
@@ -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; }
}