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:
+47
-15
@@ -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) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<button id="flt-all" aria-pressed="true">Alle</button>
|
||||
<button id="flt-local" aria-pressed="false">Nahverkehr</button>
|
||||
<button id="flt-long" aria-pressed="false">Fernverkehr</button>
|
||||
<button id="flt-city" aria-pressed="false" title="Trams und U-Bahnen">Tram/U</button>
|
||||
</div>
|
||||
<div id="search">
|
||||
<input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off"
|
||||
@@ -39,6 +40,7 @@
|
||||
<li><i style="--c:var(--na)"></i>ohne Echtzeit</li>
|
||||
</ul>
|
||||
<p id="status"></p>
|
||||
<p id="hint" hidden>Trams und U-Bahnen erscheinen beim Hineinzoomen.</p>
|
||||
</header>
|
||||
|
||||
<div class="card" id="loading" role="status">Züge werden geladen …</div>
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
|
||||
|
||||
.seg + .seg { margin-top: 6px; }
|
||||
.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 { border: 0; background: transparent; color: var(--fg); padding: 6px 11px; 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); }
|
||||
@@ -36,6 +36,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
|
||||
#results .r2 { font-size: 12px; color: var(--muted); }
|
||||
#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
|
||||
#status.warn { color: var(--d2); }
|
||||
#hint { margin: 4px 0 0; font-size: 12px; color: var(--muted); }
|
||||
|
||||
#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; }
|
||||
|
||||
Reference in New Issue
Block a user