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:
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
httpx>=0.27
|
||||
gtfs-realtime-bindings>=1.0
|
||||
@@ -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()
|
||||
@@ -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)"
|
||||
@@ -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]}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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 & 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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);
|
||||
});
|
||||
@@ -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>>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>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user