Frankreich, Niederlande, Belgien als weitere Quellen (noch im Test)
- Quellen fr (SNCF), nl (OVapi), be (GTFS.be), Kacheln für alle 5 Länder - Landesgrenzen für 5 Länder (borders.json), Gitter-Cache für die Prüfung - Echtzeit-Abgleich auch über Haltestellen-ID (FR, NL), über den Bahnhof bei Gleiswechsel - SNCF: Gattung aus der Trip-ID, Zugnummer aus dem Headsign; NL-Linien gekürzt - calendar.txt optional (SNCF hat nur calendar_dates) - OSM je Land zugeschnitten vor pfaedle; beim Zusammenführen je Objekt nur die neueste Version (time-filter) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
@@ -1,29 +1,30 @@
|
|||||||
# TrainPulse
|
# TrainPulse
|
||||||
|
|
||||||
Live-Karte aller Züge in der Schweiz und in Deutschland: Position entlang der echten Strecke,
|
Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien und den Niederlanden: Position entlang der echten Strecke,
|
||||||
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
|
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
|
||||||
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
|
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
|
||||||
|
|
||||||
## Wie es funktioniert
|
## Wie es funktioniert
|
||||||
|
|
||||||
```
|
```
|
||||||
opentransportdata.swiss ──GTFS (Mo+Do)──┐
|
Fahrpläne (GTFS) ─────────────────────────┐
|
||||||
gtfs.de (DELFI) ─────────GTFS (täglich)──┼▶ updater-Container (stündlich)
|
CH opentransportdata.swiss, DE gtfs.de, ├▶ updater-Container (stündlich)
|
||||||
│ ├─ Filter: nur Bahn (route_type 2, 100–199)
|
FR SNCF, NL OVapi, BE GTFS.be │ ├─ Filter: nur Bahn (route_type 2, 100–199)
|
||||||
OpenStreetMap CH + DE (Geofabrik) ───────┴▶ ├─ osmium: nur Schienennetz, pfaedle: Streckenverläufe
|
OpenStreetMap (Geofabrik, 5 Länder) ──────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten
|
||||||
└─▶ data/feeds/<ch|de>/<version>/
|
├─ pfaedle: Streckenverläufe
|
||||||
opentransportdata.swiss ──GTFS-RT (35 s)──┐
|
└─▶ data/feeds/<ch|de|fr|nl|be>/<version>/
|
||||||
gtfs.de ─────────────────GTFS-RT (60 s)───┴▶ web-Container (Port 8080)
|
Echtzeit (GTFS-RT) ───────────────────────┐
|
||||||
├─ Position = Fahrplan + Verspätung, entlang Strecke
|
CH (Key), DE, FR, NL └▶ web-Container (Port 8080)
|
||||||
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
|
├─ Position = Fahrplan + Verspätung, entlang Strecke
|
||||||
|
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
|
||||||
```
|
```
|
||||||
|
|
||||||
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
|
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
|
||||||
Verspätung berechnet und auf den Streckenverlauf gelegt.
|
Verspätung berechnet und auf den Streckenverlauf gelegt.
|
||||||
|
|
||||||
Beide Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt der
|
Die Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt jeder
|
||||||
Schweizer Fahrplan nur Züge innerhalb der Schweiz und der deutsche nur Züge ausserhalb
|
Fahrplan seine Züge nur im eigenen Land; im übrigen Ausland (Österreich, Italien, …) zeigen sie alle
|
||||||
(Landesgrenze aus OpenStreetMap, `trainpulse/ch_border.json`).
|
(`trainpulse/borders.json`: Schweiz aus OpenStreetMap, sonst Natural Earth).
|
||||||
|
|
||||||
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
|
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
|
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
|
||||||
@@ -37,8 +38,8 @@ die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`
|
|||||||
|
|
||||||
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
|
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
|
||||||
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim
|
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim
|
||||||
Aufbereiten bis ~3 GB, der Server ~1 GB), **30 GB Disk** (Kartenkacheln ~4,5 GB, beim
|
Aufbereiten bis ~3 GB, der Server ~1 GB), **50 GB Disk** (Kartenkacheln ~9,4 GB, beim
|
||||||
monatlichen OSM-Update kurzzeitig ~5 GB Download für Deutschland), Netz per DHCP.
|
monatlichen OSM-Update kurzzeitig bis ~6 GB Download für Frankreich), Netz per DHCP.
|
||||||
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
|
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
|
||||||
im LXC nicht.
|
im LXC nicht.
|
||||||
2. **Installieren** (im Container als root):
|
2. **Installieren** (im Container als root):
|
||||||
@@ -48,8 +49,8 @@ die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`
|
|||||||
bash Trainpulse/deploy/install.sh
|
bash Trainpulse/deploy/install.sh
|
||||||
```
|
```
|
||||||
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
|
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
|
||||||
Der erste Start dauert ca. 30–45 Minuten: Kartenkacheln (~4,5 GB) und OpenStreetMap für
|
Der erste Start dauert ca. 1–1,5 Stunden: Kartenkacheln (~9,4 GB) und OpenStreetMap für alle
|
||||||
Deutschland (~5 GB) herunterladen, dann beide Fahrpläne aufbereiten.
|
fünf Länder (~13 GB) herunterladen, dann alle Fahrpläne aufbereiten.
|
||||||
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
|
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
|
||||||
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
|
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
|
||||||
`Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
|
`Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
|
||||||
@@ -77,6 +78,10 @@ Die Hinweise erscheinen unten rechts auf der Karte.
|
|||||||
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
|
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
|
||||||
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
|
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
|
||||||
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
|
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
|
||||||
|
- Frankreich: SNCF über [transport.data.gouv.fr](https://transport.data.gouv.fr/datasets/horaires-sncf) (ODbL), Fahrplan und Echtzeit
|
||||||
|
- Niederlande: [OVapi / Stichting OpenGeo](https://gtfs.ovapi.nl), Fahrplan und Echtzeit
|
||||||
|
- Belgien: [GTFS.be](https://gtfs.be) (Community-Feed aus NMBS/SNCB-Daten), nur Fahrplan
|
||||||
|
- Landesgrenzen: [Natural Earth](https://www.naturalearthdata.com) (gemeinfrei) und OpenStreetMap
|
||||||
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
|
- 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)
|
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
|
||||||
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
|
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
|
||||||
@@ -90,7 +95,10 @@ Die Hinweise erscheinen unten rechts auf der Karte.
|
|||||||
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
|
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
|
||||||
wird als Ziel der letzte Halt.
|
wird als Ziel der letzte Halt.
|
||||||
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
|
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
|
||||||
rund 9 GB Datenverkehr pro Tag.
|
rund 9 GB Datenverkehr pro Tag; die anderen Länder zusammen ~4 GB.
|
||||||
|
- Belgien hat keine offene Echtzeit (nur nach Registrierung bei data.belgianmobility.io). Wer einen
|
||||||
|
GTFS-RT-Zugang hat, trägt URL und Key als `TRAINPULSE_BE_RT_URL` / `TRAINPULSE_BE_RT_KEY` in `.env` ein.
|
||||||
|
- Paris (RER/Transilien) ist nicht enthalten – das ist ein eigener Datensatz.
|
||||||
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
|
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
|
||||||
für ein paar Sekunden minimal ab.
|
für ein paar Sekunden minimal ab.
|
||||||
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
|
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
|
||||||
|
|||||||
+3
-3
@@ -9,7 +9,7 @@ cd "$(dirname "$0")/.."
|
|||||||
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
|
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
|
||||||
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
|
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
|
||||||
disk=$(df -Pm . | awk 'NR==2 {print $4}')
|
disk=$(df -Pm . | awk 'NR==2 {print $4}')
|
||||||
[ "$disk" -ge 25000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~30 GB" >&2
|
[ "$disk" -ge 40000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~50 GB" >&2
|
||||||
[ "$mem" -ge 5500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~6 GB empfohlen" >&2
|
[ "$mem" -ge 5500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~6 GB empfohlen" >&2
|
||||||
|
|
||||||
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
|
||||||
@@ -37,8 +37,8 @@ ip=$(hostname -I | awk '{print $1}')
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Fertig. Die Karte läuft unter http://$ip:8080
|
Fertig. Die Karte läuft unter http://$ip:8080
|
||||||
Der Updater lädt jetzt Kartenkacheln (~4,5 GB), OpenStreetMap und die Fahrpläne der Schweiz
|
Der Updater lädt jetzt Kartenkacheln (~9,4 GB), OpenStreetMap und die Fahrpläne von CH, DE, FR,
|
||||||
und Deutschlands und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 30–45 Minuten.
|
NL und BE und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 1–1,5 Stunden.
|
||||||
Fortschritt: docker compose logs -f updater
|
Fortschritt: docker compose logs -f updater
|
||||||
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
|
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+23
-2
@@ -130,6 +130,17 @@ def test_all():
|
|||||||
v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort
|
v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort
|
||||||
assert v and v[0]["properties"]["delay"] == 300
|
assert v and v[0]["properties"]["delay"] == 300
|
||||||
|
|
||||||
|
# Frankreich/Niederlande: nur stop_id, kein stop_sequence
|
||||||
|
fm = pb.FeedMessage()
|
||||||
|
fm.header.gtfs_realtime_version = "2.0"
|
||||||
|
e = fm.entity.add(id="1")
|
||||||
|
e.trip_update.trip.trip_id, e.trip_update.trip.start_date = "T1", "20261021"
|
||||||
|
st = e.trip_update.stop_time_update.add(stop_id="B")
|
||||||
|
st.arrival.delay = st.departure.delay = 300
|
||||||
|
_, rt = realtime.parse(fm.SerializeToString(), f.trip_index)
|
||||||
|
assert rt[("T1", "20261021")][1][0][0] is None
|
||||||
|
assert f.vehicles(at(8, 14), rt)[0]["properties"]["delay"] == 300
|
||||||
|
|
||||||
_, rt = realtime.parse(rt_feed(cancel=True), f.trip_index)
|
_, rt = realtime.parse(rt_feed(cancel=True), f.trip_index)
|
||||||
assert f.vehicles(at(8, 5), rt) == []
|
assert f.vehicles(at(8, 5), rt) == []
|
||||||
|
|
||||||
@@ -154,11 +165,21 @@ def test_all():
|
|||||||
assert long_distance("", "ICE 75") and long_distance("IR", "IR36") and long_distance("", "ECE 20")
|
assert long_distance("", "ICE 75") and long_distance("IR", "IR36") and long_distance("", "ECE 20")
|
||||||
assert not long_distance("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3")
|
assert not long_distance("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3")
|
||||||
assert not long_distance("TER", "K1") and not long_distance("", "")
|
assert not long_distance("TER", "K1") and not long_distance("", "")
|
||||||
|
assert long_distance("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "")
|
||||||
|
from trainpulse.feed import LINE_PREFIX
|
||||||
|
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
|
||||||
|
|
||||||
from trainpulse.border import SHOW, in_switzerland
|
from trainpulse.border import country, in_switzerland, shows
|
||||||
assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz
|
assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz
|
||||||
assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave)
|
assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave)
|
||||||
assert SHOW["ch"](8.54, 47.378) and not SHOW["de"](8.54, 47.378)
|
places = {(2.355, 48.881): "fr", (4.357, 50.836): "be", (4.900, 52.379): "nl", (13.369, 52.525): "de",
|
||||||
|
(8.540, 47.378): "ch", (6.13, 49.61): None, (11.40, 47.26): None} # Paris, Brüssel, Amsterdam,
|
||||||
|
for (x, y), c in places.items(): # Berlin, Zürich, Luxemburg, Innsbruck
|
||||||
|
assert country(x, y) == c, (x, y, country(x, y))
|
||||||
|
both = {"ch", "de"}
|
||||||
|
assert shows("ch", both, 8.54, 47.378) and not shows("de", both, 8.54, 47.378)
|
||||||
|
assert shows("de", both, 11.40, 47.26) and shows("ch", both, 11.40, 47.26) # Ausland: beide
|
||||||
|
assert shows("de", both, 2.355, 48.881) # Frankreich ohne eigenen Fahrplan -> DE zeigt
|
||||||
print("alle Tests ok")
|
print("alle Tests ok")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-11
@@ -1,12 +1,22 @@
|
|||||||
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen
|
"""Landesgrenzen, damit jeder Fahrplan seine Züge nur im eigenen Land zeigt – sonst wären
|
||||||
und Campione als Löcher). Damit zeigt jeder Fahrplan seine Züge nur im eigenen Land, sonst wären
|
grenzüberschreitende Züge doppelt auf der Karte (z. B. ICE Basel–Frankfurt in CH- und DE-Daten).
|
||||||
grenzüberschreitende Züge doppelt auf der Karte."""
|
|
||||||
|
Schweiz aus OpenStreetMap (~400 m genau, mit Büsingen und Campione als Löcher), die übrigen
|
||||||
|
Länder aus Natural Earth 1:10 Mio. (gemeinfrei), auf ~400 m vereinfacht."""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text())
|
_RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
|
||||||
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]),
|
ORDER = ["ch", "de", "fr", "be", "nl"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
|
||||||
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"]))
|
CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox(ring):
|
||||||
|
xs, ys = [p[0] for p in ring], [p[1] for p in ring]
|
||||||
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
|
||||||
|
_POLYS = {code: [(_bbox(p[0]), p) for p in _RAW[code]] for code in ORDER if code in _RAW}
|
||||||
|
|
||||||
|
|
||||||
def _in_ring(x: float, y: float, ring) -> bool:
|
def _in_ring(x: float, y: float, ring) -> bool:
|
||||||
@@ -17,11 +27,41 @@ def _in_ring(x: float, y: float, ring) -> bool:
|
|||||||
return inside
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
def _country(x: float, y: float) -> str | None:
|
||||||
|
for code, polys in _POLYS.items():
|
||||||
|
for (x0, y0, x1, y1), (outer, *holes) in polys:
|
||||||
|
if x0 <= x <= x1 and y0 <= y <= y1 and _in_ring(x, y, outer) \
|
||||||
|
and not any(_in_ring(x, y, h) for h in holes):
|
||||||
|
return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def country(lon: float, lat: float) -> str | None:
|
||||||
|
"""Ländercode (ch, de, fr, be, nl) oder None für alle anderen Länder."""
|
||||||
|
key = (round(lon / CELL), round(lat / CELL))
|
||||||
|
if key not in _cache:
|
||||||
|
if len(_cache) > 500_000:
|
||||||
|
_cache.clear()
|
||||||
|
_cache[key] = _country(key[0] * CELL, key[1] * CELL)
|
||||||
|
return _cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def bbox(code: str, margin: float = 0.6) -> str:
|
||||||
|
"""Ausschnitt eines Landes (+ ~50 km Rand) als "x0,y0,x1,y1" für osmium."""
|
||||||
|
boxes = [b for b, _ in _POLYS[code]]
|
||||||
|
return ",".join(f"{v:.2f}" for v in (min(b[0] for b in boxes) - margin, min(b[1] for b in boxes) - margin,
|
||||||
|
max(b[2] for b in boxes) + margin, max(b[3] for b in boxes) + margin))
|
||||||
|
|
||||||
|
|
||||||
def in_switzerland(lon: float, lat: float) -> bool:
|
def in_switzerland(lon: float, lat: float) -> bool:
|
||||||
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]):
|
return _country(lon, lat) == "ch"
|
||||||
return False
|
|
||||||
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
|
|
||||||
|
|
||||||
|
|
||||||
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind
|
def shows(source: str, enabled, lon: float, lat: float) -> bool:
|
||||||
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)}
|
"""Zeigt diese Quelle den Zug an dieser Stelle? Im eigenen Land ja, in einem Land mit eigenem
|
||||||
|
aktivem Fahrplan nein (dort zeigt ihn dieser). Überall sonst ja."""
|
||||||
|
c = country(lon, lat)
|
||||||
|
return c is None or c == source or c not in enabled
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26
-2
@@ -21,17 +21,41 @@ SOURCES = {
|
|||||||
"needs_key": False,
|
"needs_key": False,
|
||||||
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
|
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
|
||||||
},
|
},
|
||||||
|
"fr": { # SNCF (TGV, Intercités, TER) über transport.data.gouv.fr, ODbL
|
||||||
|
"gtfs_url": "https://eu.ftp.opendatasoft.com/sncf/plandata/Export_OpenData_SNCF_GTFS_NewTripId.zip",
|
||||||
|
"rt_url": "https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates",
|
||||||
|
"rt_key": "",
|
||||||
|
"needs_key": False,
|
||||||
|
"rt_interval": 60,
|
||||||
|
},
|
||||||
|
"nl": { # OVapi / Stichting OpenGeo; Zug-Feed statt des grossen Gesamt-Feeds
|
||||||
|
"gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip",
|
||||||
|
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb",
|
||||||
|
"rt_key": "",
|
||||||
|
"needs_key": False,
|
||||||
|
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
|
||||||
|
},
|
||||||
|
"be": { # SNCB/NMBS über GTFS.be (Community-Feed); Echtzeit nur mit Registrierung -> vorerst ohne
|
||||||
|
"gtfs_url": "https://data.gtfs.be/sncb/gtfs/be-sncb-gtfs.zip",
|
||||||
|
"rt_url": os.environ.get("TRAINPULSE_BE_RT_URL", ""),
|
||||||
|
"rt_key": os.environ.get("TRAINPULSE_BE_RT_KEY", ""),
|
||||||
|
"needs_key": False,
|
||||||
|
"rt_interval": 60,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de").split(",") if s in SOURCES]
|
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de,fr,nl,be").split(",") if s in SOURCES]
|
||||||
|
|
||||||
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
|
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
|
||||||
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
|
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
|
||||||
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
|
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
|
||||||
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
|
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
|
||||||
|
"https://download.geofabrik.de/europe/france-latest.osm.pbf",
|
||||||
|
"https://download.geofabrik.de/europe/netherlands-latest.osm.pbf",
|
||||||
|
"https://download.geofabrik.de/europe/belgium-latest.osm.pbf",
|
||||||
])).split()
|
])).split()
|
||||||
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
|
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
|
||||||
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
|
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
|
||||||
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
|
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
|
||||||
TILES_BBOX = "5.5,45.7,15.3,55.2" # Schweiz + Deutschland mit etwas Rand
|
TILES_BBOX = "-5.3,41.3,15.3,55.2" # CH, DE, FR, NL, BE mit etwas Rand
|
||||||
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
|
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
|
||||||
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
|
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
|
||||||
|
|||||||
+45
-15
@@ -59,8 +59,15 @@ def travelled(length: float, duration: float, t: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
# Gattungen des Fernverkehrs; alles andere (RE, RB, S, R, TER, …) gilt als Nahverkehr
|
# Gattungen des Fernverkehrs; alles andere (RE, RB, S, R, TER, …) gilt als Nahverkehr
|
||||||
LONG_DISTANCE = {"ICE", "IC", "ICN", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "THA", "EST", "FLX",
|
LONG_DISTANCE = {"ICE", "IC", "ICN", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA",
|
||||||
"IR", "PE", "D"}
|
"THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D"}
|
||||||
|
# Frankreich (SNCF): Gattung steht nur in der Trip-ID ("…_F:OUI:…"), die Linie heisst oft "INCONNU"
|
||||||
|
FR_CATEGORY = {"OUI": "TGV", "OGO": "OUIGO", "LYR": "Lyria", "ICE": "ICE", "IC": "IC", "ICN": "IC",
|
||||||
|
"TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"}
|
||||||
|
FR_TRIP = re.compile(r"_F:([A-Z]+):")
|
||||||
|
# Niederlande (OVapi): lange Gattungsnamen kürzen, damit die Beschriftung auf der Karte passt
|
||||||
|
LINE_ALIAS = {"Intercity": "IC", "Intercity direct": "ICD", "Sprinter": "SPR", "Nightjet": "NJ", "Eurocity": "EC"}
|
||||||
|
LINE_PREFIX = re.compile(r"^(Stoptrein|Sneltrein) (?=\S)") # "Stoptrein RS4" -> "RS4"
|
||||||
|
|
||||||
|
|
||||||
def long_distance(cat: str, line: str) -> bool:
|
def long_distance(cat: str, line: str) -> bool:
|
||||||
@@ -76,7 +83,11 @@ class Feed:
|
|||||||
self.version = self.meta["feed_version"]
|
self.version = self.meta["feed_version"]
|
||||||
|
|
||||||
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], []
|
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], []
|
||||||
|
self.stop_group = array("i") # Bahnhof eines Halts (alle Gleise eines Bahnhofs -> gleiche Zahl)
|
||||||
|
groups = {}
|
||||||
for h, r in read(path / "stops.txt"):
|
for h, r in read(path / "stops.txt"):
|
||||||
|
parent = r[h["parent_station"]] if "parent_station" in h else ""
|
||||||
|
self.stop_group.append(groups.setdefault(parent or r[h["stop_id"]], len(groups)))
|
||||||
self.stop_index[r[h["stop_id"]]] = len(self.stop_name)
|
self.stop_index[r[h["stop_id"]]] = len(self.stop_name)
|
||||||
self.stop_name.append(r[h["stop_name"]])
|
self.stop_name.append(r[h["stop_name"]])
|
||||||
self.stop_lat.append(float(r[h["stop_lat"]]))
|
self.stop_lat.append(float(r[h["stop_lat"]]))
|
||||||
@@ -86,13 +97,12 @@ class Feed:
|
|||||||
agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")}
|
agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")}
|
||||||
self.routes = {}
|
self.routes = {}
|
||||||
for h, r in read(path / "routes.txt"):
|
for h, r in read(path / "routes.txt"):
|
||||||
|
line = LINE_PREFIX.sub("", r[h["route_short_name"]] or r[h.get("route_long_name", 0)])
|
||||||
self.routes[r[h["route_id"]]] = {
|
self.routes[r[h["route_id"]]] = {
|
||||||
"line": r[h["route_short_name"]] or r[h.get("route_long_name", 0)],
|
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line),
|
||||||
"cat": r[h["route_desc"]] if "route_desc" in h else "",
|
"cat": r[h["route_desc"]] if "route_desc" in h else "",
|
||||||
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
|
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
|
||||||
}
|
}
|
||||||
route = self.routes[r[h["route_id"]]]
|
|
||||||
route["fv"] = long_distance(route["cat"], route["line"])
|
|
||||||
|
|
||||||
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
|
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
|
||||||
self.trip_headsign, self.trip_number, self.trip_shape = [], [], []
|
self.trip_headsign, self.trip_number, self.trip_shape = [], [], []
|
||||||
@@ -104,6 +114,15 @@ class Feed:
|
|||||||
self.trip_headsign.append(r[h["trip_headsign"]] if "trip_headsign" in h else "")
|
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_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 "")
|
self.trip_shape.append(r[h["shape_id"]] if "shape_id" in h else "")
|
||||||
|
route = self.routes[r[h["route_id"]]]
|
||||||
|
m = FR_TRIP.search(r[h["trip_id"]]) if not route["cat"] else None
|
||||||
|
if m:
|
||||||
|
route["cat"] = FR_CATEGORY.get(m.group(1), m.group(1))
|
||||||
|
if self.trip_headsign[-1].isdigit() and not self.trip_number[-1]: # SNCF: Ziel = Zugnummer
|
||||||
|
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
|
||||||
|
for route in self.routes.values():
|
||||||
|
route["line"] = route["line"] or route["cat"]
|
||||||
|
route["fv"] = long_distance(route["cat"], route["line"])
|
||||||
|
|
||||||
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
|
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
|
||||||
n = len(self.trip_route)
|
n = len(self.trip_route)
|
||||||
@@ -136,7 +155,7 @@ class Feed:
|
|||||||
self.shape_data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)).cast("f")
|
self.shape_data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)).cast("f")
|
||||||
|
|
||||||
self.calendar = {}
|
self.calendar = {}
|
||||||
for h, r in read(path / "calendar.txt"):
|
for h, r in read(path / "calendar.txt") if (path / "calendar.txt").exists() else (): # SNCF: nur Ausnahmen
|
||||||
self.calendar[r[h["service_id"]]] = (
|
self.calendar[r[h["service_id"]]] = (
|
||||||
[r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")],
|
[r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")],
|
||||||
r[h["start_date"]], r[h["end_date"]])
|
r[h["start_date"]], r[h["end_date"]])
|
||||||
@@ -226,22 +245,33 @@ class Feed:
|
|||||||
arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
|
arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
|
||||||
if not upd:
|
if not upd:
|
||||||
return arr, dep
|
return arr, dep
|
||||||
seqs = self.st_seq[o0:o1]
|
seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1]
|
||||||
by_i = {}
|
by_i, j = {}, 0
|
||||||
for u in upd:
|
for u in upd:
|
||||||
i = bisect_right(seqs, u[0]) - 1
|
if u[0] is not None: # Schweiz, Deutschland: über die Halt-Nummer
|
||||||
if i >= 0 and seqs[i] == u[0]:
|
i = bisect_right(seqs, u[0]) - 1
|
||||||
by_i[i] = u
|
if i >= 0 and seqs[i] == u[0]:
|
||||||
|
by_i[i] = u
|
||||||
|
continue
|
||||||
|
# Frankreich, Niederlande: nur Haltestellen-ID; über den Bahnhof abgleichen, damit es
|
||||||
|
# auch bei Gleiswechsel passt, und der Reihe nach (eine Fahrt kann einen Bahnhof zweimal haben)
|
||||||
|
k = self.stop_index.get(u[1])
|
||||||
|
if k is None:
|
||||||
|
continue
|
||||||
|
for i in range(j, len(stops)):
|
||||||
|
if self.stop_group[stops[i]] == self.stop_group[k]:
|
||||||
|
by_i[i], j = u, i + 1
|
||||||
|
break
|
||||||
cur, prev = None, -10 ** 9
|
cur, prev = None, -10 ** 9
|
||||||
for i in range(len(arr)):
|
for i in range(len(arr)):
|
||||||
u = by_i.get(i)
|
u = by_i.get(i)
|
||||||
if u and u[1] is not None:
|
if u and u[2] is not None:
|
||||||
a = u[1] - ds if u[2] else arr[i] + u[1]
|
a = u[2] - ds if u[3] else arr[i] + u[2]
|
||||||
cur = a - arr[i]
|
cur = a - arr[i]
|
||||||
else:
|
else:
|
||||||
a = arr[i] + (cur or 0)
|
a = arr[i] + (cur or 0)
|
||||||
if u and u[3] is not None:
|
if u and u[4] is not None:
|
||||||
d = u[3] - ds if u[4] else dep[i] + u[3]
|
d = u[4] - ds if u[5] else dep[i] + u[4]
|
||||||
cur = d - dep[i]
|
cur = d - dep[i]
|
||||||
else:
|
else:
|
||||||
d = dep[i] + (cur or 0)
|
d = dep[i] + (cur or 0)
|
||||||
|
|||||||
+18
-8
@@ -21,6 +21,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from . import border
|
||||||
from . import config as C
|
from . import config as C
|
||||||
|
|
||||||
log = logging.getLogger("prepare")
|
log = logging.getLogger("prepare")
|
||||||
@@ -189,8 +190,12 @@ def ensure_osm() -> Path:
|
|||||||
"-o", str(part), "--overwrite"], check=True)
|
"-o", str(part), "--overwrite"], check=True)
|
||||||
raw.unlink() # spart Platz
|
raw.unlink() # spart Platz
|
||||||
parts.append(str(part))
|
parts.append(str(part))
|
||||||
tmp = d / "rail.tmp.osm.pbf"
|
merged, tmp = d / "rail.merged.osm.pbf", d / "rail.tmp.osm.pbf"
|
||||||
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True)
|
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(merged), "--overwrite"], check=True)
|
||||||
|
# Die Länder-Dateien sind unterschiedlich alt: Objekte an den Grenzen stecken dann in zwei
|
||||||
|
# Versionen drin, woran osmium extract scheitert. time-filter behält je Objekt die neueste.
|
||||||
|
subprocess.run([C.OSMIUM, "time-filter", str(merged), "-o", str(tmp), "--overwrite"], check=True)
|
||||||
|
merged.unlink()
|
||||||
tmp.rename(rail)
|
tmp.rename(rail)
|
||||||
urls_file.write_text(wanted)
|
urls_file.write_text(wanted)
|
||||||
for p in parts:
|
for p in parts:
|
||||||
@@ -200,19 +205,24 @@ def ensure_osm() -> Path:
|
|||||||
return rail
|
return rail
|
||||||
|
|
||||||
|
|
||||||
def run_pfaedle(work: Path, rail: Path) -> Path | None:
|
def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
|
||||||
if not shutil.which(C.PFAEDLE):
|
if not shutil.which(C.PFAEDLE):
|
||||||
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
|
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
|
||||||
return None
|
return None
|
||||||
osm = ensure_osm()
|
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
|
||||||
log.info("pfaedle rechnet Streckenverläufe …")
|
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
|
||||||
|
osm = work / "region.osm.pbf"
|
||||||
|
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm),
|
||||||
|
"--overwrite"], check=True)
|
||||||
|
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
|
||||||
t = time.time()
|
t = time.time()
|
||||||
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work)
|
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work)
|
||||||
|
osm.unlink()
|
||||||
out = work / "gtfs-out"
|
out = work / "gtfs-out"
|
||||||
if res.returncode != 0 or not (out / "shapes.txt").exists():
|
if res.returncode != 0 or not (out / "shapes.txt").exists():
|
||||||
log.error("pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", res.returncode)
|
log.error("[%s] pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", source, res.returncode)
|
||||||
return None
|
return None
|
||||||
log.info("pfaedle fertig in %.0f s", time.time() - t)
|
log.info("[%s] pfaedle fertig in %.0f s", source, time.time() - t)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -306,7 +316,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
|
|||||||
if zip_path.parent == work:
|
if zip_path.parent == work:
|
||||||
zip_path.unlink()
|
zip_path.unlink()
|
||||||
|
|
||||||
shaped = run_pfaedle(work, work / "rail")
|
shaped = run_pfaedle(work, work / "rail", source)
|
||||||
meta["shapes"] = shaped is not None
|
meta["shapes"] = shaped is not None
|
||||||
if shaped:
|
if shaped:
|
||||||
pack_shapes(shaped)
|
pack_shapes(shaped)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ CANCELED = pb.TripDescriptor.CANCELED
|
|||||||
|
|
||||||
|
|
||||||
def parse(data: bytes, known_trips: dict):
|
def parse(data: bytes, known_trips: dict):
|
||||||
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, ank, ank_absolut, abf, abf_absolut), ...])})
|
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, stop_id, ank, ank_absolut, abf, abf_absolut), ...])})
|
||||||
|
seq ist None, wenn die Quelle nur die Haltestellen-ID liefert (Frankreich, Niederlande).
|
||||||
ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
|
ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
|
||||||
feed = pb.FeedMessage()
|
feed = pb.FeedMessage()
|
||||||
feed.ParseFromString(data)
|
feed.ParseFromString(data)
|
||||||
@@ -28,7 +29,7 @@ def parse(data: bytes, known_trips: dict):
|
|||||||
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
|
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
|
||||||
else:
|
else:
|
||||||
ev += [None, False]
|
ev += [None, False]
|
||||||
upd.append((s.stop_sequence, *ev))
|
upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev))
|
||||||
out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd)
|
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
|
version = feed.header.feed_version if feed.header.HasField("feed_version") else None
|
||||||
return version, out
|
return version, out
|
||||||
|
|||||||
@@ -110,10 +110,9 @@ def poll_realtime(src: Source):
|
|||||||
|
|
||||||
|
|
||||||
def positions(src: Source, t: float) -> list:
|
def positions(src: Source, t: float) -> list:
|
||||||
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
|
|
||||||
feats = []
|
feats = []
|
||||||
for f in src.feed.vehicles(t, src.rt):
|
for f in src.feed.vehicles(t, src.rt):
|
||||||
if show and not show(*f["geometry"]["coordinates"]):
|
if len(S.sources) > 1 and not border.shows(src.name, S.sources, *f["geometry"]["coordinates"]):
|
||||||
continue # gehört zum Fahrplan des Nachbarlandes
|
continue # gehört zum Fahrplan des Nachbarlandes
|
||||||
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
|
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
|
||||||
feats.append(f)
|
feats.append(f)
|
||||||
|
|||||||
+6
-4
@@ -25,8 +25,8 @@ const delayColor = ["case",
|
|||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: "map",
|
container: "map",
|
||||||
center: [9.6, 49.2], // Deutschland + Schweiz
|
center: [6.5, 49.0], // CH, DE, FR, BE, NL
|
||||||
zoom: 5.6,
|
zoom: 5.2,
|
||||||
hash: true,
|
hash: true,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
@@ -51,7 +51,9 @@ const map = new maplibregl.Map({
|
|||||||
trains: {
|
trains: {
|
||||||
type: "geojson", data: EMPTY,
|
type: "geojson", data: EMPTY,
|
||||||
attribution: 'Fahrplan & Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
|
attribution: 'Fahrplan & Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
|
||||||
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0)',
|
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0), '
|
||||||
|
+ '<a href="https://transport.data.gouv.fr/datasets/horaires-sncf">SNCF</a> (ODbL), '
|
||||||
|
+ '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://gtfs.be">GTFS.be</a> (NMBS/SNCB)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
@@ -247,7 +249,7 @@ async function refreshStatus() {
|
|||||||
const s = await (await fetch("api/status")).json();
|
const s = await (await fetch("api/status")).json();
|
||||||
if (!s.ready) {
|
if (!s.ready) {
|
||||||
loading.textContent = s.loaded ? "Züge werden geladen …"
|
loading.textContent = s.loaded ? "Züge werden geladen …"
|
||||||
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 30–45 Minuten.";
|
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden.";
|
||||||
loading.hidden = false;
|
loading.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user