diff --git a/tests/test_core.py b/tests/test_core.py
index d855a07..3fbddb3 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -163,6 +163,7 @@ def test_all():
_, 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]["lon"], det["stops"][1]["lat"]) == (8.1, 47.0) # Halt auf der Strecke
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)
diff --git a/trainpulse/feed.py b/trainpulse/feed.py
index 055ad8e..24eb138 100644
--- a/trainpulse/feed.py
+++ b/trainpulse/feed.py
@@ -398,11 +398,12 @@ class Feed:
def hhmm(sec):
return dt.datetime.fromtimestamp(ds + sec, TZ).strftime("%H:%M")
- stops = []
+ stops, g = [], self.geometry(ti)
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],
+ **dict(zip(("lon", "lat"), self.point_at(g, g[2][k]))), # Punkt auf der Strecke
"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,
diff --git a/trainpulse/server.py b/trainpulse/server.py
index 30871ce..018b350 100644
--- a/trainpulse/server.py
+++ b/trainpulse/server.py
@@ -35,6 +35,7 @@ class Source:
self.rt_error: str | None = None
self.rt_tried_at: float | None = None
self.on_disk = False # ein aufbereiteter Fahrplan liegt bereit (wird gleich geladen)
+ self.loaded_at = 0.0
@property
def has_rt(self) -> bool:
@@ -84,6 +85,7 @@ def watch_feeds():
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want)
+ src.loaded_at = time.time()
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
@@ -138,6 +140,18 @@ def compute_positions():
f["path"] = src.feed.track(f.pop("_ti"), dists, STEP)
del f["_dist"]
feats += cur
+ # Im übrigen Ausland (Luxemburg, Österreich, …) zeigen alle Fahrpläne ihre Züge -> dort Doppelte
+ # (gleiche Linie, gleiche ~500-m-Zelle) entfernen, bevorzugt den mit Echtzeit behalten
+ seen, out = set(), []
+ for f in sorted(feats, key=lambda f: f["properties"]["delay"] is None):
+ x, y = f["geometry"]["coordinates"]
+ if border.country(x, y) is None:
+ line, cx, cy = f["properties"]["line"], round(x * 200), round(y * 200)
+ if any((line, cx + i, cy + j) in seen for i in (-1, 0, 1) for j in (-1, 0, 1)):
+ continue
+ seen.add((line, cx, cy))
+ out.append(f)
+ feats = out
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats},
separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5)
@@ -194,7 +208,8 @@ def status():
return {
"loaded": bool(loaded),
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
- "ready": bool(loaded and not pending and S.computed_at and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
+ "ready": bool(loaded and not pending and S.computed_at
+ and all(S.computed_at >= s.loaded_at for s in loaded) and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"rt_ok": bool(ok) and len(ok) == len(loaded),
"rt_some": bool(ok),
diff --git a/web/app.js b/web/app.js
index 7642362..f69bf21 100644
--- a/web/app.js
+++ b/web/app.js
@@ -6,8 +6,14 @@ maplibregl.addProtocol("pmtiles", protocol.tile);
// Drei Grundkarten-Sätze im selben Style, umgeschaltet per Sichtbarkeit:
// hell, dunkel, und Beschriftung für Satellit (weisse Schrift mit dunklem Rand, wie bei Google Maps)
+// Orte: ohne weissen Punkt, Name direkt auf der Ortslage statt daneben
+function centerPlace(l) {
+ if (l.id !== "places_locality") return l.layout;
+ const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout;
+ return { ...rest, "text-anchor": "center" };
+}
const prefixed = (layers, pre, extra = {}) => layers.filter((l) => l.id !== "roads_shields").map((l) => ({ ...l, id: pre + l.id,
- layout: { ...l.layout, visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
+ layout: { ...centerPlace(l), visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
const LAYERS = {
light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"),
dark: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { lang: "de" }), "d-"),
@@ -48,6 +54,7 @@ const map = new maplibregl.Map({
attribution: '© swisstopo',
},
route: { type: "geojson", data: EMPTY },
+ "route-stops": { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan & Echtzeit: opentransportdata.swiss, '
@@ -62,6 +69,8 @@ const map = new maplibregl.Map({
...LAYERS.light, ...LAYERS.dark, ...LAYERS.sat,
{ 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: "route-stops", type: "circle", source: "route-stops", // Halte: Linie dort etwas dicker
+ paint: { "circle-color": "#2563eb", "circle-radius": ["interpolate", ["linear"], ["zoom"], 5, 4.5, 12, 7] } },
{ id: "trains", type: "symbol", source: "trains",
layout: {
// Tropfen mit Spitze in Fahrtrichtung; Züge ohne bekannte Richtung als Punkt
@@ -319,12 +328,15 @@ async function showTrip(id) {
`;
document.getElementById("panel").hidden = false;
map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } });
+ map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => (
+ { type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
}
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
map.getSource("route").setData(EMPTY);
+ map.getSource("route-stops").setData(EMPTY);
}
document.getElementById("close").onclick = closeTrip;