Trams und U-Bahnen (CH, DE, NL): eigener Filter, ab Stadtzoom im Ausschnitt; NL-Echtzeit auch für Tram/Metro; Vorausschau ohne 7-fache Neuberechnung; deutsche Steig-Zusätze aus Haltestellennamen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-22 10:33:45 +02:00
parent 49860c50f5
commit d11cc109f6
9 changed files with 148 additions and 56 deletions
+47 -15
View File
@@ -77,7 +77,10 @@ const map = new maplibregl.Map({
"icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map",
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.3, 9, 0.5, 12, 0.9, 14, 1.3],
// Trams/U-Bahnen etwas kleiner als Züge
// (zoom darf nur zuoberst in interpolate stehen, darum der Faktor in jedem Stützwert)
"icon-size": ["interpolate", ["linear"], ["zoom"],
...[[6, 0.3], [9, 0.5], [12, 0.9], [14, 1.3]].flatMap(([z, v]) => [z, ["*", v, ["case", ["==", ["get", "mode"], "rail"], 1, 0.75]]])],
"icon-allow-overlap": true, "icon-ignore-placement": true,
},
paint: {
@@ -119,13 +122,15 @@ function setMode(mode) {
}
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Filter: alle / Nahverkehr / Fernverkehr ----------
// ---------- Filter: alle / Nahverkehr / Fernverkehr / Tram & U-Bahn ----------
const FILTERS = { all: null, local: ["!", ["get", "fv"]], long: ["get", "fv"] };
const RAIL = ["==", ["get", "mode"], "rail"];
const FILTERS = { all: null, local: ["all", RAIL, ["!", ["get", "fv"]]], long: ["get", "fv"], city: ["!", RAIL] };
function setFilter(name) {
if (!(name in FILTERS)) name = "all";
for (const id of ["trains", "train-labels"]) map.setFilter(id, FILTERS[name]);
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).setAttribute("aria-pressed", String(n === name));
updateHint();
try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ }
}
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclick = () => setFilter(n);
@@ -135,20 +140,48 @@ for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclic
// 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, clockOffset = 0;
let clockOffset = 0;
const URBAN_Z = 11; // Trams/U-Bahnen erst ab hier (vorher nur Gewimmel, und sonst zu viele Daten)
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
const now = Number(r.headers.get("X-Now"));
if (now) clockOffset = Date.now() / 1000 - now;
const d = await r.json();
for (const f of d.features) f.t0 = d.ts; // Gleispunkt-Zeiten zählen ab der Berechnung auf dem Server
return d.features;
}
function updateHint() {
const city = document.getElementById("flt-city").getAttribute("aria-pressed") === "true";
document.getElementById("hint").hidden = !(city && map.getZoom() < URBAN_Z);
}
map.on("zoomend", updateHint);
// beim Verschieben/Zoomen Trams nachladen, wenn der Ausschnitt den zuletzt geladenen verlässt
map.on("moveend", () => {
if (!ready) return;
const b = map.getBounds(), want = map.getZoom() >= URBAN_Z;
const inside = urbanBox && b.getWest() >= urbanBox[0] && b.getSouth() >= urbanBox[1]
&& b.getEast() <= urbanBox[2] && b.getNorth() <= urbanBox[3];
if (want ? !inside : urbanBox) refreshTrains();
});
let urbanBox = null; // zuletzt abgefragter Ausschnitt (mit Rand), um beim Verschieben nachzuladen
async function refreshTrains() {
if (document.hidden || !ready) return;
try {
const r = await fetch("api/vehicles");
if (!r.ok) return;
const now = Number(r.headers.get("X-Now"));
const d = await r.json();
if (now) clockOffset = Date.now() / 1000 - now;
let urban = [];
if (map.getZoom() >= URBAN_Z) {
const b = map.getBounds(), dx = (b.getEast() - b.getWest()) * 0.3, dy = (b.getNorth() - b.getSouth()) * 0.3;
urbanBox = [b.getWest() - dx, b.getSouth() - dy, b.getEast() + dx, b.getNorth() + dy];
urban = await getJSON("api/urban?bbox=" + urbanBox.map((v) => v.toFixed(4)).join(","));
} else urbanBox = null;
const features = [...(await getJSON("api/vehicles")), ...urban];
const old = new Map(trains.features.map((f) => [f.properties.id, f]));
for (const f of d.features) {
for (const f of features) {
f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts, m ab Fahrtbeginn]
const o = old.get(f.properties.id);
if (o) {
@@ -157,8 +190,7 @@ async function refreshTrains() {
f.shownD = o.shownD;
}
}
trains = d;
ts = d.ts;
trains = { type: "FeatureCollection", features };
render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
}
@@ -230,12 +262,12 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
let lastRender = 0;
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
function render() {
const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server
const now = Date.now() / 1000 - clockOffset;
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;
const p = f.pts, t = now - f.t0; // s seit Berechnung auf dem Server
if (!p) continue;
// Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
let i = 0;
@@ -286,7 +318,7 @@ async function refreshStatus() {
}
loading.hidden = true; // auch nach "Keine Verbindung" wieder weg
const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some
el.textContent = `${s.vehicles} Züge` + (s.urban ? `, ${s.urban} Trams/U-Bahnen` : "") + " unterwegs" + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn";
} catch (e) {