diff --git a/tests/test_core.py b/tests/test_core.py index 2260b43..c4b071e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -114,6 +114,17 @@ def test_all(): assert abs(v[0]["properties"]["heading"] - 90) < 15, v[0]["properties"]["heading"] # Scheitel: Richtung Osten assert f.vehicles(at(8, 11), {})[0]["properties"]["heading"] is not None # auch im Stillstand + # Gleisverlauf für den Browser: folgt der Kurve über den Umweg-Scheitel, Zeiten steigen an + tr = f.track(0, [3000, 4500, 6000], 2) + assert tr[0][2] == 0 and tr[-1][2] == 4 and [p[2] for p in tr] == sorted(p[2] for p in tr) + assert any(abs(p[0] - 8.05) < 1e-4 and abs(p[1] - 46.98) < 1e-4 for p in tr), tr # Scheitelpunkt drin + assert len(f.track(0, [5000, 5000, 5000], 2)) == 3 # steht: Punkte bleiben (Zeit läuft) + + # Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben + from trainpulse.feed import with_min_dwell + pa, pd = with_min_dwell([0, 600, 1200], [0, 600, 1200]) + assert pa[1] == 580 and pd[1] == 620 and pa[0] == 0 and pd[2] == 1200 + # Anfahren/Bremsen: symmetrisch, am Anfang/Ende langsamer als linear, Gesamtstrecke stimmt from trainpulse.feed import travelled assert travelled(8000, 600, 0) == 0 and abs(travelled(8000, 600, 600) - 8000) < 1e-6 diff --git a/trainpulse/feed.py b/trainpulse/feed.py index 9fd11fa..055ad8e 100644 --- a/trainpulse/feed.py +++ b/trainpulse/feed.py @@ -76,6 +76,48 @@ def long_distance(cat: str, line: str) -> bool: return bool(m) and m.group(0).upper() in LONG_DISTANCE +MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten + + +def with_min_dwell(arr, dep): + """Halte mit weniger als MIN_DWELL Haltezeit für die Animation verlängern: etwas früher ankommen, + etwas später abfahren – höchstens ein Viertel der Fahrzeit davor/danach.""" + pa, pd = list(arr), list(dep) + for k in range(1, len(arr) - 1): + short = MIN_DWELL - (dep[k] - arr[k]) + if short > 0: + h = min(short / 2, (arr[k] - dep[k - 1]) / 4, (arr[k + 1] - dep[k]) / 4) + if h > 0: + pa[k], pd[k] = arr[k] - h, dep[k] + h + return pa, pd + + +def simplify(p: list, tol_m: float, fixed=()) -> list: + """Douglas-Peucker auf [lon, lat, t]-Punkten (Toleranz in Metern, grob über die Breite umgerechnet). + Punkte in `fixed` bleiben immer erhalten.""" + if len(p) < 3: + return p + kx = 111320 * math.cos(math.radians(p[0][1])) + keep = {0, len(p) - 1, *fixed} + cuts = sorted(keep) + stack = list(zip(cuts, cuts[1:])) + while stack: + a, b = stack.pop() + (x1, y1), (x2, y2) = (p[a][0] * kx, p[a][1] * 110540), (p[b][0] * kx, p[b][1] * 110540) + dx, dy = x2 - x1, y2 - y1 + n = math.hypot(dx, dy) + best, idx = 0.0, -1 + for i in range(a + 1, b): + x, y = p[i][0] * kx, p[i][1] * 110540 + d = abs(dy * x - dx * y + x2 * y1 - y2 * x1) / n if n else math.hypot(x - x1, y - y1) + if d > best: + best, idx = d, i + if best > tol_m: + keep.add(idx) + stack += [(a, idx), (idx, b)] + return [p[i] for i in sorted(keep)] + + class Feed: def __init__(self, path: Path, today: dt.date | None = None): self.path = path @@ -227,6 +269,23 @@ class Feed: self._geom_cache[key] = g return g + def track(self, ti: int, dists: list, step: float, tolerance: float = 3.0) -> list: + """Gleispunkte von der aktuellen Position bis zur Position in len(dists)-1 Schritten, je mit der + Zeit (s ab jetzt), zu der der Zug dort ist. Gerade Stücke werden auf ~3 m vereinfacht.""" + pts, cum, _ = self.geometry(ti) + out, fixed = [], [] + for k in range(len(dists)): + d0 = dists[k] + fixed.append(len(out)) # Zeitpunkte alle `step` s behalten, sonst ginge das Bremsen verloren + out.append((d0, k * step)) + if k + 1 < len(dists) and dists[k + 1] > d0: + d1 = dists[k + 1] + for j in range(bisect_right(cum, d0), bisect_right(cum, d1)): + if d0 < cum[j] < d1: + out.append((cum[j], (k + (cum[j] - d0) / (d1 - d0)) * step)) + res = [[*self.point_at((pts, cum, None), d), round(t, 2)] for d, t in out] + return simplify(res, tolerance, fixed) + @staticmethod def point_at(g, d: float): pts, cum, _ = g @@ -299,14 +358,16 @@ class Feed: 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) + pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt + i = min(bisect_right(pd, t), len(pd) - 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] + if t >= pa[i]: # steht am Halt i + dist = sd[i] + delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i] else: - dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], arr[i] - dep[i - 1], t - dep[i - 1]) + dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], t - pd[i - 1]) delay = arr[i] - self.st_arr[o + i] lon, lat = self.point_at(g, dist) # Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand @@ -323,6 +384,7 @@ class Feed: "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]], "heading": heading, }, + "_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt }) return feats diff --git a/trainpulse/prepare.py b/trainpulse/prepare.py index 8f9b5af..ff5cdc3 100644 --- a/trainpulse/prepare.py +++ b/trainpulse/prepare.py @@ -195,8 +195,10 @@ def ensure_osm() -> Path: # 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) + # IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM für die weit verstreuten OSM-IDs + subprocess.run([C.OSMIUM, "renumber", str(tmp), "-o", str(merged), "--overwrite"], check=True) + tmp.unlink() + merged.rename(rail) urls_file.write_text(wanted) for p in parts: Path(p).unlink() diff --git a/trainpulse/server.py b/trainpulse/server.py index e92aa96..30871ce 100644 --- a/trainpulse/server.py +++ b/trainpulse/server.py @@ -19,7 +19,7 @@ from . import border, realtime from .feed import TZ, Feed log = logging.getLogger("trainpulse") -STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Browser Kurven nachfährt +STEP, STEPS = 2, 6 # Gleisverlauf für die nächsten 12 s (Abruf alle 5 s, Berechnung alle 3 s -> Reserve) WEB = Path(__file__).resolve().parent.parent / "web" @@ -121,23 +121,24 @@ def positions(src: Source, t: float) -> list: def compute_positions(): now = time.time() - feats, future = [], [{} for _ in range(STEPS)] + feats = [] for src in list(S.sources.values()): if not src.feed: continue - feats += positions(src, now) - for k in range(STEPS): - future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]] - for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)}) - for f in feats: - path = [] - for pos in future: - p = pos.get(f["properties"]["id"]) - if p is None: - break # Fahrt endet - path.append(p) - f["path"] = path - S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "step": STEP, "features": feats}, + cur = positions(src, now) + # Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt + future = [{f["properties"]["id"]: f["_dist"] for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)} + for k in range(STEPS)] # Schlüssel Betriebstag/Fahrt: Nachtzüge laufen über Mitternacht + for f in cur: + key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]] + for pos in future: + if key not in pos: + break # Fahrt endet + dists.append(pos[key]) + f["path"] = src.feed.track(f.pop("_ti"), dists, STEP) + del f["_dist"] + feats += cur + S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats}, separators=(",", ":")).encode() S.vehicles_gz = gzip.compress(S.vehicles, 5) S.count, S.computed_at = len(feats), time.time() diff --git a/web/app.js b/web/app.js index f22a184..7642362 100644 --- a/web/app.js +++ b/web/app.js @@ -123,10 +123,10 @@ for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclic // ---------- Züge ---------- -// Server liefert je Zug die Position jetzt und alle `step` Sekunden danach (entlang der Strecke); -// dazwischen wird gefahren, und die Spitze dreht sich weich in die jeweilige Richtung. +// Server liefert je Zug die Gleispunkte der nächsten ~10 s, jeweils mit der Zeit, zu der der Zug dort ist; +// der Browser fährt sie ab, die Spitze dreht sich weich in die Richtung des aktuellen Gleisstücks. let trains = { type: "FeatureCollection", features: [] }; -let ts = 0, step = 2, clockOffset = 0; +let ts = 0, clockOffset = 0; let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat @@ -140,19 +140,26 @@ async function refreshTrains() { if (now) clockOffset = Date.now() / 1000 - now; const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing])); for (const f of d.features) { - f.pts = [[...f.geometry.coordinates, f.properties.heading], ...(f.path || [])]; + f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts] const b = old.get(f.properties.id); if (b !== undefined) f.properties.bearing = b; } trains = d; ts = d.ts; - step = d.step || 2; + render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild } catch (e) { /* nächster Versuch in ein paar Sekunden */ } } // kürzester Drehwinkel von a nach b in Grad (-180..180) const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180; +// Richtung von a nach b in Grad (0 = Norden), in Mercator wie die Karte; null wenn (fast) gleicher Punkt +function bearing(a, b) { + const y = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + const dx = ((b[0] - a[0]) * Math.PI) / 180, dy = y(b[1]) - y(a[1]); + return Math.hypot(dx, dy) < 1e-7 ? null : (Math.atan2(dx, dy) * 180) / Math.PI; +} + // SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann const S = 64, R = 8; function sdfIcon(mask) { @@ -209,21 +216,22 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => { let lastRender = 0; function render() { - const t = Date.now() / 1000 - clockOffset - ts; + const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen lastRender = performance.now(); const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s for (const f of trains.features) { const p = f.pts; if (!p) continue; - const x = Math.max(0, t / step); - const i = Math.min(Math.floor(x), p.length - 1); - const a = p[i], b = p[Math.min(i + 1, p.length - 1)], k = i + 1 < p.length ? x - i : 0; + // Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren + let i = 0; + while (i + 1 < p.length && p[i + 1][2] <= t) i++; + const a = p[i], b = p[Math.min(i + 1, p.length - 1)]; + const k = b[2] > a[2] ? Math.max(0, Math.min(1, (t - a[2]) / (b[2] - a[2]))) : 0; f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; - // Richtung kommt vom Server (Gleisverlauf an der Position); dazwischen interpolieren und weich nachdrehen - const ha = a[2] ?? b[2], hb = b[2] ?? a[2]; - if (ha === null || ha === undefined) continue; - const target = ha + turn(ha, hb) * k; + // Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt) + const target = bearing(a, b) ?? f.properties.heading; + if (target === null || target === undefined) continue; const cur = f.properties.bearing; f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease; }