diff --git a/trainpulse/feed.py b/trainpulse/feed.py
index 07754e7..beca1a0 100644
--- a/trainpulse/feed.py
+++ b/trainpulse/feed.py
@@ -386,7 +386,7 @@ class Feed:
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"],
- "nr": self.trip_number[ti], "to": self.trip_headsign[ti],
+ "nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti],
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading,
},
diff --git a/web/app.js b/web/app.js
index 1fa13f9..2109aae 100644
--- a/web/app.js
+++ b/web/app.js
@@ -342,6 +342,60 @@ async function showTrip(id) {
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
}
+// ---------- Suche ----------
+// Gleiche Linie fährt oft mehrfach gleichzeitig (S5 in Zürich, Bern, München …): darum je Treffer
+// Start → Ziel und nächster Halt, die nächstgelegenen zuerst
+const norm = (s) => String(s ?? "").toLowerCase().replace(/\s+/g, "");
+let hits = [], sel = -1;
+function search() {
+ const q = norm(document.getElementById("q").value), list = document.getElementById("results");
+ if (!q) { list.hidden = true; hits = []; return; }
+ const c = map.getCenter(), kx = Math.cos((c.lat * Math.PI) / 180);
+ const dist = (f) => Math.hypot((f.geometry.coordinates[0] - c.lng) * kx, f.geometry.coordinates[1] - c.lat);
+ hits = trains.features.map((f) => {
+ const p = f.properties, line = norm(p.line);
+ const rank = line === q || norm(p.nr) === q ? 0 : line.startsWith(q) ? 1
+ : norm(p.to).includes(q) || norm(p.from).includes(q) ? 2 : -1;
+ return [rank, dist(f), f];
+ }).filter((h) => h[0] >= 0).sort((a, b) => a[0] - b[0] || a[1] - b[1]).slice(0, 30).map((h) => h[2]);
+ sel = -1;
+ list.innerHTML = hits.length ? hits.map((f, i) => {
+ const p = f.properties;
+ return `
${esc(p.line)}${p.nr ? " " + esc(p.nr) : ""}${delayBadge(p.delay)}
+ ${esc(p.from)} → ${esc(p.to)}
nächster Halt: ${esc(p.next)}
`;
+ }).join("") : 'kein fahrender Zug gefunden';
+ list.hidden = false;
+}
+function pick(i) {
+ const f = hits[i];
+ if (!f) return;
+ 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 (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) });
+ showTrip(f.properties.id);
+ document.getElementById("results").hidden = true;
+}
+const qEl = document.getElementById("q");
+qEl.addEventListener("input", search);
+qEl.addEventListener("focus", search);
+qEl.addEventListener("keydown", (e) => {
+ const items = document.querySelectorAll("#results li[data-i]");
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ if (!items.length) return;
+ sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
+ items.forEach((li, i) => li.classList.toggle("sel", i === sel));
+ items[sel].scrollIntoView({ block: "nearest" });
+ } else if (e.key === "Enter") pick(sel >= 0 ? sel : 0);
+ else if (e.key === "Escape") { document.getElementById("results").hidden = true; qEl.blur(); }
+});
+document.getElementById("results").addEventListener("click", (e) => {
+ const li = e.target.closest("li[data-i]");
+ if (li) pick(Number(li.dataset.i));
+});
+map.on("click", () => { document.getElementById("results").hidden = true; });
+
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
diff --git a/web/index.html b/web/index.html
index 9323168..3c2c585 100644
--- a/web/index.html
+++ b/web/index.html
@@ -26,6 +26,11 @@
+
- pünktlich
- 3–6′
diff --git a/web/style.css b/web/style.css
index cff5595..476e519 100644
--- a/web/style.css
+++ b/web/style.css
@@ -25,6 +25,15 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
.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 i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--c); margin-right: 4px; }
+#search { margin-top: 8px; }
+#q { width: 100%; box-sizing: border-box; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
+ background: var(--bg); color: var(--fg); font: inherit; }
+#results { list-style: none; margin: 4px 0 0; padding: 0; max-height: 45vh; overflow: auto; }
+#results[hidden] { display: none; }
+#results li { padding: 6px 4px; border-top: 1px solid var(--line); cursor: pointer; }
+#results li:hover, #results li.sel { background: var(--line); }
+#results .r1 { display: flex; justify-content: space-between; gap: 8px; }
+#results .r2 { font-size: 12px; color: var(--muted); }
#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
#status.warn { color: var(--d2); }