Ebenen statt Filter (Fern, Nah, Tram, U-Bahn einzeln oder alle); Haltename beim Darüberfahren; Lade-Hinweis nach Server-Neustart

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 13:30:42 +02:00
parent d11cc109f6
commit a7daa76535
3 changed files with 52 additions and 25 deletions
+45 -19
View File
@@ -122,18 +122,34 @@ function setMode(mode) {
} }
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m); for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Filter: alle / Nahverkehr / Fernverkehr / Tram & U-Bahn ---------- // ---------- Ebenen: Fernverkehr / Nahverkehr / Tram / U-Bahn, einzeln oder alle ----------
const RAIL = ["==", ["get", "mode"], "rail"]; const LAYER_FILTERS = {
const FILTERS = { all: null, local: ["all", RAIL, ["!", ["get", "fv"]]], long: ["get", "fv"], city: ["!", RAIL] }; local: ["all", ["==", ["get", "mode"], "rail"], ["!", ["get", "fv"]]], long: ["get", "fv"],
function setFilter(name) { tram: ["==", ["get", "mode"], "tram"], subway: ["==", ["get", "mode"], "subway"],
if (!(name in FILTERS)) name = "all"; };
for (const id of ["trains", "train-labels"]) map.setFilter(id, FILTERS[name]); const ALL_LAYERS = Object.keys(LAYER_FILTERS);
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).setAttribute("aria-pressed", String(n === name)); const layerOf = (p) => (p.mode === "rail" ? (p.fv ? "long" : "local") : p.mode);
let shown = new Set(ALL_LAYERS);
function setLayers(list) {
shown = new Set(list.filter((k) => k in LAYER_FILTERS));
if (!shown.size) shown = new Set(ALL_LAYERS); // nichts gewählt = alles
const all = shown.size === ALL_LAYERS.length;
const filter = all ? null : ["any", ...[...shown].map((k) => LAYER_FILTERS[k])];
for (const id of ["trains", "train-labels"]) map.setFilter(id, filter);
document.getElementById("flt-all").setAttribute("aria-pressed", String(all));
for (const k of ALL_LAYERS) document.getElementById("flt-" + k).setAttribute("aria-pressed", String(!all && shown.has(k)));
updateHint(); updateHint();
try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ } try { localStorage.setItem("layers", JSON.stringify([...shown])); } catch (e) { /* privater Modus */ }
} }
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclick = () => setFilter(n); document.getElementById("flt-all").onclick = () => setLayers(ALL_LAYERS);
for (const k of ALL_LAYERS) document.getElementById("flt-" + k).onclick = () => {
// aus "Alle" heraus: nur diese Ebene; sonst Ebene dazu oder weg
if (shown.size === ALL_LAYERS.length) return setLayers([k]);
const next = new Set(shown);
next.has(k) ? next.delete(k) : next.add(k);
setLayers([...next]);
};
// ---------- Züge ---------- // ---------- Züge ----------
@@ -156,8 +172,9 @@ async function getJSON(url) {
} }
function updateHint() { function updateHint() {
const city = document.getElementById("flt-city").getAttribute("aria-pressed") === "true"; // nur Trams/U-Bahnen gewählt, aber zu weit weg: sonst wäre die Karte einfach leer
document.getElementById("hint").hidden = !(city && map.getZoom() < URBAN_Z); const onlyCity = !shown.has("long") && !shown.has("local");
document.getElementById("hint").hidden = !(onlyCity && map.getZoom() < URBAN_Z);
} }
map.on("zoomend", updateHint); map.on("zoomend", updateHint);
// beim Verschieben/Zoomen Trams nachladen, wenn der Ausschnitt den zuletzt geladenen verlässt // beim Verschieben/Zoomen Trams nachladen, wenn der Ausschnitt den zuletzt geladenen verlässt
@@ -306,7 +323,9 @@ async function refreshStatus() {
const el = document.getElementById("status"), loading = document.getElementById("loading"); const el = document.getElementById("status"), loading = document.getElementById("loading");
try { try {
const s = await (await fetch("api/status")).json(); const s = await (await fetch("api/status")).json();
if (!s.ready && !ready) { // nur beim Start; danach meldet der Server nach jedem Echtzeit-Abruf kurz "nicht bereit" // beim Start oder nach Server-Neustart (noch keine Züge); sonst meldet der Server nach jedem
// Echtzeit-Abruf nur kurz "nicht bereit"
if (!s.ready && (!ready || !s.vehicles)) {
loading.textContent = s.loaded ? "Züge werden geladen …" loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden."; : "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden.";
loading.hidden = false; loading.hidden = false;
@@ -329,7 +348,7 @@ async function refreshStatus() {
async function statusLoop() { // bis alles bereit ist alle 2 s nachfragen, danach alle 30 s async function statusLoop() { // bis alles bereit ist alle 2 s nachfragen, danach alle 30 s
await refreshStatus(); await refreshStatus();
setTimeout(statusLoop, ready ? 30000 : 2000); setTimeout(statusLoop, ready && document.getElementById("loading").hidden ? 30000 : 2000);
} }
// ---------- Zugdetails ---------- // ---------- Zugdetails ----------
@@ -371,7 +390,7 @@ async function showTrip(id) {
document.getElementById("panel").hidden = false; document.getElementById("panel").hidden = false;
map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } }); map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } });
map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => ( map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => (
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) }); { type: "Feature", properties: { name: x.name }, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
} }
// ---------- Suche ---------- // ---------- Suche ----------
@@ -402,8 +421,7 @@ function pick(i) {
const f = hits[i]; const f = hits[i];
if (!f) return; if (!f) return;
const now = trains.features.find((x) => x.properties.id === f.properties.id) || f; // aktuelle Position const now = trains.features.find((x) => x.properties.id === f.properties.id) || f; // aktuelle Position
const pressed = Object.keys(FILTERS).find((n) => document.getElementById("flt-" + n).getAttribute("aria-pressed") === "true"); if (!shown.has(layerOf(f.properties))) setLayers([...shown, layerOf(f.properties)]); // sonst unsichtbar
if (pressed === "local" && f.properties.fv || pressed === "long" && !f.properties.fv) setFilter("all"); // sonst unsichtbar
map.flyTo({ center: now.geometry.coordinates, zoom: Math.max(map.getZoom(), 12) }); map.flyTo({ center: now.geometry.coordinates, zoom: Math.max(map.getZoom(), 12) });
showTrip(f.properties.id); showTrip(f.properties.id);
document.getElementById("results").hidden = true; document.getElementById("results").hidden = true;
@@ -453,13 +471,21 @@ map.on("mousemove", "trains", (e) => {
}); });
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); hoverId = null; }); map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); hoverId = null; });
// Halte der geöffneten Fahrt: Name beim Darüberfahren
const stopHover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 8, className: "hover" });
map.on("mousemove", "route-stops", (e) => {
if (hoverId) return; // Zug-Kärtchen hat Vorrang
stopHover.setText(e.features[0].properties.name).setLngLat(e.features[0].geometry.coordinates).addTo(map);
});
map.on("mouseleave", "route-stops", () => stopHover.remove());
map.on("load", () => { map.on("load", () => {
let mode = "light"; let mode = "light";
try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ } try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ }
setMode(mode); setMode(mode);
let filter = "all"; let layers = ALL_LAYERS;
try { filter = localStorage.getItem("filter") || "all"; } catch (e) { /* egal */ } try { layers = JSON.parse(localStorage.getItem("layers")) || ALL_LAYERS; } catch (e) { /* egal */ }
setFilter(filter); setLayers(Array.isArray(layers) ? layers : ALL_LAYERS);
statusLoop(); statusLoop();
setInterval(refreshTrains, 5000); setInterval(refreshTrains, 5000);
requestAnimationFrame(animate); requestAnimationFrame(animate);
+4 -3
View File
@@ -21,11 +21,12 @@
<button id="btn-dark" aria-pressed="false">Dunkel</button> <button id="btn-dark" aria-pressed="false">Dunkel</button>
<button id="btn-sat" aria-pressed="false">Satellit</button> <button id="btn-sat" aria-pressed="false">Satellit</button>
</div> </div>
<div class="seg" role="group" aria-label="Züge"> <div class="seg" role="group" aria-label="Verkehrsmittel">
<button id="flt-all" aria-pressed="true">Alle</button> <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-long" aria-pressed="false">Fernverkehr</button>
<button id="flt-city" aria-pressed="false" title="Trams und U-Bahnen">Tram/U</button> <button id="flt-local" aria-pressed="false" title="Regionalzüge und S-Bahnen">Nahverkehr</button>
<button id="flt-tram" aria-pressed="false">Tram</button>
<button id="flt-subway" aria-pressed="false">U-Bahn</button>
</div> </div>
<div id="search"> <div id="search">
<input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off" <input id="q" type="search" placeholder="Zug suchen, z. B. S5, ICE 11, 2318" autocomplete="off"
+3 -3
View File
@@ -14,13 +14,13 @@ html, body { margin: 0; height: 100%; }
position: absolute; z-index: 2; background: var(--bg); border-radius: 10px; position: absolute; z-index: 2; background: var(--bg); border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,.18); box-shadow: 0 2px 12px rgba(0,0,0,.18);
} }
#bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(340px, calc(100vw - 24px)); box-sizing: border-box; } #bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(380px, calc(100vw - 24px)); box-sizing: border-box; }
h1 { margin: 0 0 8px; font-size: 16px; letter-spacing: .02em; } h1 { margin: 0 0 8px; font-size: 16px; letter-spacing: .02em; }
h1::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); margin-right: 7px; } h1::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); margin-right: 7px; }
.seg + .seg { margin-top: 6px; } .seg + .seg { margin-top: 6px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 11px; font: inherit; cursor: pointer; } .seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 8px; font: inherit; cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } .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); } .legend { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--muted); }